From 1e617025dfadbc0117f9b27227fe0eb7ee0f146a Mon Sep 17 00:00:00 2001 From: Zimeng Xiong Date: Fri, 6 Feb 2026 14:11:13 -0800 Subject: [PATCH] Add admin password reset flow --- backend/package-lock.json | 70 ++ backend/package.json | 2 + .../migration.sql | 5 + backend/prisma/schema.prisma | 13 +- backend/scripts/admin-recover.cjs | 183 +++ backend/src/auth.ts | 1048 ++++++++++++++++- backend/src/index.ts | 935 +++++++++++---- backend/src/middleware/auth.ts | 35 +- frontend/src/App.tsx | 9 + frontend/src/api/index.ts | 18 + frontend/src/components/ConfirmModal.tsx | 6 +- frontend/src/components/FingerprintAvatar.tsx | 125 ++ frontend/src/components/Layout.tsx | 142 ++- frontend/src/components/ProtectedRoute.tsx | 10 +- frontend/src/components/Sidebar.tsx | 74 +- frontend/src/pages/Admin.tsx | 761 ++++++++++++ frontend/src/pages/Dashboard.tsx | 21 +- frontend/src/pages/Login.tsx | 230 +++- frontend/src/pages/PasswordResetRequest.tsx | 108 +- frontend/src/pages/Profile.tsx | 226 +++- frontend/src/pages/Settings.tsx | 615 ++++++---- frontend/src/utils/impersonation.ts | 47 + frontend/src/utils/importUtils.ts | 220 ++++ 23 files changed, 4205 insertions(+), 698 deletions(-) create mode 100644 backend/prisma/migrations/20260206195000_add_auth_login_rate_limit_config/migration.sql create mode 100644 backend/scripts/admin-recover.cjs create mode 100644 frontend/src/components/FingerprintAvatar.tsx create mode 100644 frontend/src/pages/Admin.tsx create mode 100644 frontend/src/utils/impersonation.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 85a51af..0cb3700 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -29,6 +29,7 @@ "helmet": "^8.1.0", "jsdom": "^22.1.0", "jsonwebtoken": "^9.0.3", + "jszip": "^3.10.1", "ms": "^2.1.3", "multer": "^2.0.2", "prisma": "^5.22.0", @@ -3150,6 +3151,12 @@ "dev": true, "license": "ISC" }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -3371,6 +3378,48 @@ "npm": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -3434,6 +3483,15 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lodash": { "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", @@ -3880,6 +3938,12 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -4393,6 +4457,12 @@ "node": ">= 18" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", diff --git a/backend/package.json b/backend/package.json index 2a469ec..ffd25fd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,6 +6,7 @@ "scripts": { "predev": "node scripts/predev-migrate.cjs", "dev": "nodemon src/index.ts", + "admin:recover": "node scripts/admin-recover.cjs", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage" @@ -35,6 +36,7 @@ "helmet": "^8.1.0", "jsdom": "^22.1.0", "jsonwebtoken": "^9.0.3", + "jszip": "^3.10.1", "ms": "^2.1.3", "multer": "^2.0.2", "prisma": "^5.22.0", diff --git a/backend/prisma/migrations/20260206195000_add_auth_login_rate_limit_config/migration.sql b/backend/prisma/migrations/20260206195000_add_auth_login_rate_limit_config/migration.sql new file mode 100644 index 0000000..6c38218 --- /dev/null +++ b/backend/prisma/migrations/20260206195000_add_auth_login_rate_limit_config/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "SystemConfig" ADD COLUMN "authLoginRateLimitEnabled" BOOLEAN NOT NULL DEFAULT 1; +ALTER TABLE "SystemConfig" ADD COLUMN "authLoginRateLimitWindowMs" INTEGER NOT NULL DEFAULT 900000; +ALTER TABLE "SystemConfig" ADD COLUMN "authLoginRateLimitMax" INTEGER NOT NULL DEFAULT 20; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 40cf1e0..a1302a3 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -31,11 +31,14 @@ model User { } model SystemConfig { - id String @id @default("default") - authEnabled Boolean @default(false) - registrationEnabled Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default("default") + authEnabled Boolean @default(false) + registrationEnabled Boolean @default(false) + authLoginRateLimitEnabled Boolean @default(true) + authLoginRateLimitWindowMs Int @default(900000) // 15 minutes + authLoginRateLimitMax Int @default(20) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model Collection { diff --git a/backend/scripts/admin-recover.cjs b/backend/scripts/admin-recover.cjs new file mode 100644 index 0000000..73c70f9 --- /dev/null +++ b/backend/scripts/admin-recover.cjs @@ -0,0 +1,183 @@ +#!/usr/bin/env node + +/** + * CLI admin password recovery for ExcaliDash. + * + * Examples: + * node scripts/admin-recover.cjs --identifier admin@example.com --password "NewStrongPassword!" + * node scripts/admin-recover.cjs --identifier admin@example.com --generate + * + * Notes: + * - Works with SQLite DATABASE_URL (default: file:./prisma/dev.db). + * - Sets the password hash and clears mustResetPassword by default. + * - If there are no active admins, this script can promote the target user to ADMIN. + */ + +require("dotenv").config(); + +const path = require("path"); +process.env.DATABASE_URL = + process.env.DATABASE_URL || + `file:${path.resolve(__dirname, "../prisma/dev.db")}`; + +const { PrismaClient } = require("../src/generated/client"); +const bcrypt = require("bcrypt"); + +const parseArgs = (argv) => { + const args = {}; + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (!token.startsWith("--")) continue; + const key = token.slice(2); + const next = argv[i + 1]; + if (!next || next.startsWith("--")) { + args[key] = true; + } else { + args[key] = next; + i += 1; + } + } + return args; +}; + +const generatePassword = () => { + // 24 chars base64url-ish + const buf = require("crypto").randomBytes(18); + return buf.toString("base64").replace(/[+/=]/g, "").slice(0, 24); +}; + +const main = async () => { + const args = parseArgs(process.argv.slice(2)); + + const identifier = typeof args.identifier === "string" ? args.identifier.trim() : ""; + const providedPassword = typeof args.password === "string" ? args.password : null; + const generate = Boolean(args.generate); + const setMustReset = Boolean(args["must-reset"]); + const activate = Boolean(args.activate); + const promote = Boolean(args.promote); + const disableLoginRateLimit = Boolean(args["disable-login-rate-limit"]); + + if (!identifier) { + console.error("Missing --identifier (email or username)."); + process.exitCode = 2; + return; + } + + let newPassword = providedPassword; + if (!newPassword) { + if (!generate) { + console.error('Provide --password "" or pass --generate.'); + process.exitCode = 2; + return; + } + newPassword = generatePassword(); + } + + if (newPassword.length < 8) { + console.error("Password must be at least 8 characters."); + process.exitCode = 2; + return; + } + + const prisma = new PrismaClient(); + + try { + const activeAdminCount = await prisma.user.count({ + where: { role: "ADMIN", isActive: true }, + }); + + const trimmed = identifier.toLowerCase(); + const user = await prisma.user.findFirst({ + where: { + OR: [{ email: trimmed }, { username: identifier }], + }, + select: { + id: true, + email: true, + username: true, + role: true, + isActive: true, + mustResetPassword: true, + }, + }); + + if (!user) { + console.error("User not found:", identifier); + process.exitCode = 1; + return; + } + + const shouldPromote = promote || activeAdminCount === 0; + + if (user.role !== "ADMIN" && !shouldPromote) { + console.error("Target user is not an ADMIN. Refusing to reset password for non-admin user."); + console.error("Tip: pass --promote to promote this user to ADMIN, or use it only when there are 0 active admins."); + process.exitCode = 1; + return; + } + + const saltRounds = 10; + const passwordHash = await bcrypt.hash(newPassword, saltRounds); + + if (disableLoginRateLimit) { + await prisma.systemConfig.upsert({ + where: { id: "default" }, + update: { authLoginRateLimitEnabled: false }, + create: { + id: "default", + authEnabled: true, + registrationEnabled: false, + authLoginRateLimitEnabled: false, + authLoginRateLimitWindowMs: 15 * 60 * 1000, + authLoginRateLimitMax: 20, + }, + }); + } + + const updated = await prisma.user.update({ + where: { id: user.id }, + data: { + passwordHash, + mustResetPassword: setMustReset ? true : false, + isActive: activate ? true : user.isActive, + role: shouldPromote ? "ADMIN" : user.role, + }, + select: { + id: true, + email: true, + username: true, + role: true, + isActive: true, + mustResetPassword: true, + }, + }); + + console.log("Updated admin account:"); + console.log(`- id: ${updated.id}`); + console.log(`- email: ${updated.email}`); + console.log(`- username: ${updated.username || ""}`); + console.log(`- isActive: ${updated.isActive}`); + console.log(`- mustResetPassword: ${updated.mustResetPassword}`); + console.log(`- role: ${updated.role}`); + if (disableLoginRateLimit) { + console.log(""); + console.log("Login rate limiting: DISABLED (SystemConfig.authLoginRateLimitEnabled=false)."); + console.log("Remember to re-enable it from the Admin dashboard after you regain access."); + } + if (generate || !providedPassword) { + console.log(""); + console.log("New password:"); + console.log(newPassword); + } else { + console.log(""); + console.log("Password updated."); + } + } finally { + await prisma.$disconnect().catch(() => {}); + } +}; + +main().catch((err) => { + console.error("Admin recovery failed:", err); + process.exitCode = 1; +}); diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 1674f5f..95a0558 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -10,7 +10,7 @@ import { PrismaClient } from "./generated/client"; import { config } from "./config"; import { requireAuth, optionalAuth } from "./middleware/auth"; import { sanitizeText } from "./security"; -import rateLimit from "express-rate-limit"; +import rateLimit, { MemoryStore } from "express-rate-limit"; import { logAuditEvent } from "./utils/audit"; import crypto from "crypto"; @@ -18,6 +18,7 @@ interface JwtPayload { userId: string; email: string; type: "access" | "refresh"; + impersonatorId?: string; } /** @@ -49,6 +50,9 @@ const ensureSystemConfig = async () => { id: DEFAULT_SYSTEM_CONFIG_ID, authEnabled: false, registrationEnabled: false, + authLoginRateLimitEnabled: true, + authLoginRateLimitWindowMs: 15 * 60 * 1000, + authLoginRateLimitMax: 20, }, }); }; @@ -65,13 +69,99 @@ const ensureAuthEnabled = async (res: Response): Promise => { return true; }; -// Rate limiting for auth endpoints (stricter than general rate limiting) -const authRateLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 5, // 5 requests per window +type LoginRateLimitConfig = { + enabled: boolean; + windowMs: number; + max: number; +}; + +const DEFAULT_LOGIN_RATE_LIMIT: LoginRateLimitConfig = { + enabled: true, + windowMs: 15 * 60 * 1000, + max: 20, +}; + +let loginRateLimitConfig: LoginRateLimitConfig = { ...DEFAULT_LOGIN_RATE_LIMIT }; +let loginAttemptLimiter: ReturnType | null = null; +let loginLimiterInitPromise: Promise | null = null; + +const parseLoginRateLimitConfig = (systemConfig: Awaited>): LoginRateLimitConfig => { + const enabled = typeof systemConfig.authLoginRateLimitEnabled === "boolean" ? systemConfig.authLoginRateLimitEnabled : DEFAULT_LOGIN_RATE_LIMIT.enabled; + const windowMs = + Number.isFinite(Number(systemConfig.authLoginRateLimitWindowMs)) && Number(systemConfig.authLoginRateLimitWindowMs) > 0 + ? Number(systemConfig.authLoginRateLimitWindowMs) + : DEFAULT_LOGIN_RATE_LIMIT.windowMs; + const max = + Number.isFinite(Number(systemConfig.authLoginRateLimitMax)) && Number(systemConfig.authLoginRateLimitMax) > 0 + ? Number(systemConfig.authLoginRateLimitMax) + : DEFAULT_LOGIN_RATE_LIMIT.max; + return { enabled, windowMs, max }; +}; + +const resolveAuthIdentifier = (req: Request): string | null => { + const body = (req.body || {}) as Record; + const raw = + (typeof body.email === "string" && body.email) || + (typeof body.username === "string" && body.username) || + (typeof body.identifier === "string" && body.identifier) || + null; + if (!raw) return null; + const trimmed = raw.trim().toLowerCase(); + return trimmed.length > 0 ? trimmed.slice(0, 255) : null; +}; + +const buildLoginAttemptLimiter = (cfg: LoginRateLimitConfig) => { + const store = new MemoryStore(); + const limiter = rateLimit({ + windowMs: cfg.windowMs, + max: cfg.max, + message: { + error: "Too many requests", + message: "Too many login attempts, please try again later", + }, + standardHeaders: true, + legacyHeaders: false, + store, + keyGenerator: (req) => { + const identifier = resolveAuthIdentifier(req as Request); + if (identifier) return `login:${identifier}`; + const ip = (req as Request).ip || "unknown"; + return `login-ip:${ip}`; + }, + }); + + loginAttemptLimiter = limiter; +}; + +const initLoginAttemptLimiter = async () => { + const systemConfig = await ensureSystemConfig(); + loginRateLimitConfig = parseLoginRateLimitConfig(systemConfig); + buildLoginAttemptLimiter(loginRateLimitConfig); +}; + +const ensureLoginAttemptLimiter = async () => { + if (loginAttemptLimiter) return; + if (!loginLimiterInitPromise) { + loginLimiterInitPromise = initLoginAttemptLimiter().finally(() => { + loginLimiterInitPromise = null; + }); + } + await loginLimiterInitPromise; +}; + +const loginAttemptRateLimiter = async (req: Request, res: Response, next: express.NextFunction) => { + await ensureLoginAttemptLimiter(); + if (!loginRateLimitConfig.enabled) return next(); + return (loginAttemptLimiter as ReturnType)(req, res, next); +}; + +// Rate limiting for authenticated account/admin actions (more lenient) +const accountActionRateLimiter = rateLimit({ + windowMs: 5 * 60 * 1000, // 5 minutes + max: 60, message: { error: "Too many requests", - message: "Too many authentication attempts, please try again later", + message: "Too many requests, please try again later", }, standardHeaders: true, legacyHeaders: false, @@ -109,6 +199,49 @@ const authEnabledToggleSchema = z.object({ enabled: z.boolean(), }); +const adminCreateUserSchema = z.object({ + username: z.string().trim().min(3).max(50).optional(), + email: z.string().email().toLowerCase().trim(), + password: z.string().min(8).max(100), + name: z.string().trim().min(1).max(100), + role: z.enum(["ADMIN", "USER"]).optional(), + mustResetPassword: z.boolean().optional(), + isActive: z.boolean().optional(), +}); + +const adminUpdateUserSchema = z.object({ + username: z.string().trim().min(3).max(50).nullable().optional(), + name: z.string().trim().min(1).max(100).optional(), + role: z.enum(["ADMIN", "USER"]).optional(), + mustResetPassword: z.boolean().optional(), + isActive: z.boolean().optional(), +}); + +const impersonateSchema = z + .object({ + userId: z.string().trim().min(1).optional(), + identifier: z.string().trim().min(1).optional(), + }) + .refine((data) => Boolean(data.userId || data.identifier), { + message: "userId/identifier is required", + }); + +const loginRateLimitUpdateSchema = z.object({ + enabled: z.boolean(), + windowMs: z.number().int().min(10_000).max(24 * 60 * 60 * 1000), + max: z.number().int().min(1).max(10_000), +}); + +const loginRateLimitResetSchema = z.object({ + identifier: z.string().trim().min(1).max(255), +}); + +const generateTempPassword = (): string => { + // 24 chars base64-ish + const buf = crypto.randomBytes(18); + return buf.toString("base64").replace(/[+/=]/g, "").slice(0, 24); +}; + const findUserByIdentifier = async (identifier: string) => { const trimmed = identifier.trim(); if (trimmed.length === 0) return null; @@ -127,18 +260,43 @@ const findUserByIdentifier = async (identifier: string) => { }); }; +const requireAdmin = ( + req: Request, + res: Response +): req is Request & { user: NonNullable } => { + if (!req.user) { + res.status(401).json({ error: "Unauthorized", message: "User not authenticated" }); + return false; + } + if (req.user.role !== "ADMIN") { + res.status(403).json({ error: "Forbidden", message: "Admin access required" }); + return false; + } + return true; +}; + +const countActiveAdmins = async () => { + return prisma.user.count({ + where: { role: "ADMIN", isActive: true }, + }); +}; + /** * Generate JWT tokens (access and refresh) * Note: expiresIn accepts string (like "15m", "7d") or number (seconds) */ -const generateTokens = (userId: string, email: string) => { +const generateTokens = ( + userId: string, + email: string, + options?: { impersonatorId?: string } +) => { // jwt.sign accepts StringValue | number for expiresIn // Our config provides strings which are compatible with StringValue const signOptions: SignOptions = { expiresIn: config.jwtAccessExpiresIn as StringValue, }; const accessToken = jwt.sign( - { userId, email, type: "access" }, + { userId, email, type: "access", impersonatorId: options?.impersonatorId }, config.jwtSecret, signOptions ); @@ -147,7 +305,7 @@ const generateTokens = (userId: string, email: string) => { expiresIn: config.jwtRefreshExpiresIn as StringValue, }; const refreshToken = jwt.sign( - { userId, email, type: "refresh" }, + { userId, email, type: "refresh", impersonatorId: options?.impersonatorId }, config.jwtSecret, refreshSignOptions ); @@ -168,7 +326,7 @@ const getRefreshTokenExpiresAt = (): Date => * POST /auth/register * Register a new user */ -router.post("/register", authRateLimiter, async (req: Request, res: Response) => { +router.post("/register", loginAttemptRateLimiter, async (req: Request, res: Response) => { try { if (!(await ensureAuthEnabled(res))) return; const parsed = registerSchema.safeParse(req.body); @@ -399,7 +557,7 @@ router.post("/register", authRateLimiter, async (req: Request, res: Response) => * POST /auth/login * Login with email and password */ -router.post("/login", authRateLimiter, async (req: Request, res: Response) => { +router.post("/login", loginAttemptRateLimiter, async (req: Request, res: Response) => { try { if (!(await ensureAuthEnabled(res))) return; const parsed = loginSchema.safeParse(req.body); @@ -604,7 +762,11 @@ router.post("/refresh", async (req: Request, res: Response) => { }); // Generate new tokens (rotation) - const { accessToken, refreshToken: newRefreshToken } = generateTokens(user.id, user.email); + const { accessToken, refreshToken: newRefreshToken } = generateTokens( + user.id, + user.email, + { impersonatorId: decoded.impersonatorId } + ); // Store new refresh token const expiresAt = getRefreshTokenExpiresAt(); @@ -635,7 +797,12 @@ router.post("/refresh", async (req: Request, res: Response) => { expiresIn: config.jwtAccessExpiresIn as StringValue, }; const accessToken = jwt.sign( - { userId: user.id, email: user.email, type: "access" }, + { + userId: user.id, + email: user.email, + type: "access", + impersonatorId: decoded.impersonatorId, + }, config.jwtSecret, signOptions ); @@ -741,6 +908,7 @@ router.get("/status", optionalAuth, async (req: Request, res: Response) => { name: req.user.name, role: req.user.role, mustResetPassword: req.user.mustResetPassword ?? false, + impersonatorId: req.user.impersonatorId, } : null, }); @@ -844,12 +1012,7 @@ router.post("/auth-enabled", optionalAuth, async (req: Request, res: Response) = router.post("/registration/toggle", requireAuth, async (req: Request, res: Response) => { try { if (!(await ensureAuthEnabled(res))) return; - if (!req.user) { - return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" }); - } - if (req.user.role !== "ADMIN") { - return res.status(403).json({ error: "Forbidden", message: "Admin access required" }); - } + if (!requireAdmin(req, res)) return; const parsed = registrationToggleSchema.safeParse(req.body); if (!parsed.success) { @@ -879,12 +1042,7 @@ router.post("/registration/toggle", requireAuth, async (req: Request, res: Respo router.post("/admins", requireAuth, async (req: Request, res: Response) => { try { if (!(await ensureAuthEnabled(res))) return; - if (!req.user) { - return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" }); - } - if (req.user.role !== "ADMIN") { - return res.status(403).json({ error: "Forbidden", message: "Admin access required" }); - } + if (!requireAdmin(req, res)) return; const parsed = adminRoleUpdateSchema.safeParse(req.body); if (!parsed.success) { @@ -896,6 +1054,23 @@ router.post("/admins", requireAuth, async (req: Request, res: Response) => { return res.status(404).json({ error: "Not found", message: "User not found" }); } + if (target.id === req.user.id && parsed.data.role !== "ADMIN") { + return res.status(409).json({ + error: "Conflict", + message: "You cannot change your own role from ADMIN", + }); + } + + if (target.role === "ADMIN" && parsed.data.role !== "ADMIN" && target.isActive) { + const admins = await countActiveAdmins(); + if (admins <= 1) { + return res.status(409).json({ + error: "Conflict", + message: "There must be at least one active admin", + }); + } + } + const updated = await prisma.user.update({ where: { id: target.id }, data: { role: parsed.data.role }, @@ -920,6 +1095,539 @@ router.post("/admins", requireAuth, async (req: Request, res: Response) => { } }); +/** + * GET /auth/users + * List users (admin-only) + */ +router.get("/users", requireAuth, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireAdmin(req, res)) return; + + const users = await prisma.user.findMany({ + orderBy: [{ createdAt: "asc" }], + select: { + id: true, + username: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + isActive: true, + createdAt: true, + updatedAt: true, + }, + }); + + res.json({ users }); + } catch (error) { + console.error("List users error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to list users", + }); + } +}); + +/** + * GET /auth/rate-limit/login + * Get login rate limit config (admin-only) + */ +router.get("/rate-limit/login", requireAuth, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireAdmin(req, res)) return; + + const systemConfig = await ensureSystemConfig(); + const cfg = parseLoginRateLimitConfig(systemConfig); + res.json({ config: cfg }); + } catch (error) { + console.error("Get login rate limit config error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to fetch login rate limit config", + }); + } +}); + +/** + * PUT /auth/rate-limit/login + * Update login rate limit config (admin-only) + */ +router.put("/rate-limit/login", requireAuth, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireAdmin(req, res)) return; + + const parsed = loginRateLimitUpdateSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: "Validation error", + message: "Invalid rate limit config", + }); + } + + const updated = await prisma.systemConfig.update({ + where: { id: DEFAULT_SYSTEM_CONFIG_ID }, + data: { + authLoginRateLimitEnabled: parsed.data.enabled, + authLoginRateLimitWindowMs: parsed.data.windowMs, + authLoginRateLimitMax: parsed.data.max, + }, + }); + + loginRateLimitConfig = parseLoginRateLimitConfig(updated); + buildLoginAttemptLimiter(loginRateLimitConfig); + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: req.user.id, + action: "admin_login_rate_limit_updated", + resource: "system_config", + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { ...loginRateLimitConfig }, + }); + } + + res.json({ config: loginRateLimitConfig }); + } catch (error) { + console.error("Update login rate limit config error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to update login rate limit config", + }); + } +}); + +/** + * POST /auth/rate-limit/login/reset + * Reset login rate limit for an identifier (admin-only) + */ +router.post("/rate-limit/login/reset", requireAuth, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireAdmin(req, res)) return; + + const parsed = loginRateLimitResetSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: "Validation error", + message: "Invalid reset payload", + }); + } + + await ensureLoginAttemptLimiter(); + + const identifier = parsed.data.identifier.trim().toLowerCase(); + const key = `login:${identifier}`; + + try { + await loginAttemptLimiter?.resetKey(key); + } catch (error) { + // Best-effort; store may not support resetKey + if (process.env.NODE_ENV === "development") { + console.debug("Rate limit reset skipped:", error); + } + } + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: req.user.id, + action: "admin_login_rate_limit_reset", + resource: `rate_limit:${key}`, + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { identifier }, + }); + } + + res.json({ ok: true }); + } catch (error) { + console.error("Reset login rate limit error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to reset login rate limit", + }); + } +}); + +/** + * POST /auth/users + * Create user (admin-only) + */ +router.post("/users", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireAdmin(req, res)) return; + + const parsed = adminCreateUserSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: "Validation error", + message: "Invalid user payload", + }); + } + + const { email, password, name, username, role, mustResetPassword, isActive } = parsed.data; + + const existingUser = await prisma.user.findUnique({ where: { email } }); + if (existingUser) { + return res.status(409).json({ + error: "Conflict", + message: "User with this email already exists", + }); + } + + if (username) { + const existingUsername = await prisma.user.findFirst({ + where: { username }, + select: { id: true }, + }); + if (existingUsername) { + return res.status(409).json({ + error: "Conflict", + message: "User with this username already exists", + }); + } + } + + const saltRounds = 10; + const passwordHash = await bcrypt.hash(password, saltRounds); + const sanitizedName = sanitizeText(name, 100); + + const user = await prisma.user.create({ + data: { + email, + username: username ?? null, + passwordHash, + name: sanitizedName, + role: role ?? "USER", + mustResetPassword: mustResetPassword ?? false, + isActive: isActive ?? true, + }, + select: { + id: true, + username: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + isActive: true, + createdAt: true, + updatedAt: true, + }, + }); + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: req.user.id, + action: "admin_user_created", + resource: `user:${user.id}`, + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { createdUserId: user.id }, + }); + } + + res.status(201).json({ user }); + } catch (error) { + console.error("Create user error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to create user", + }); + } +}); + +/** + * PATCH /auth/users/:id + * Update user (admin-only) + */ +router.patch("/users/:id", requireAuth, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireAdmin(req, res)) return; + + const userId = String(req.params.id || "").trim(); + if (!userId) { + return res.status(400).json({ error: "Bad request", message: "Invalid user id" }); + } + + const parsed = adminUpdateUserSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "Bad request", message: "Invalid update payload" }); + } + + // Prevent admin locking themselves out accidentally. + if (userId === req.user.id && parsed.data.isActive === false) { + return res.status(409).json({ + error: "Conflict", + message: "You cannot deactivate your own account", + }); + } + + if (userId === req.user.id && parsed.data.role && parsed.data.role !== "ADMIN") { + return res.status(409).json({ + error: "Conflict", + message: "You cannot change your own role from ADMIN", + }); + } + + const current = await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, role: true, isActive: true }, + }); + + if (!current) { + return res.status(404).json({ error: "Not found", message: "User not found" }); + } + + const nextRole = typeof parsed.data.role === "undefined" ? current.role : parsed.data.role; + const nextActive = + typeof parsed.data.isActive === "undefined" ? current.isActive : parsed.data.isActive; + + const removingAdmin = + current.role === "ADMIN" && + current.isActive && + (nextRole !== "ADMIN" || nextActive === false); + + if (removingAdmin) { + const admins = await countActiveAdmins(); + if (admins <= 1) { + return res.status(409).json({ + error: "Conflict", + message: "There must be at least one active admin", + }); + } + } + + const data: Record = {}; + if (typeof parsed.data.username !== "undefined") data.username = parsed.data.username; + if (typeof parsed.data.name !== "undefined") data.name = sanitizeText(parsed.data.name, 100); + if (typeof parsed.data.role !== "undefined") data.role = parsed.data.role; + if (typeof parsed.data.mustResetPassword !== "undefined") + data.mustResetPassword = parsed.data.mustResetPassword; + if (typeof parsed.data.isActive !== "undefined") data.isActive = parsed.data.isActive; + + const updated = await prisma.user.update({ + where: { id: userId }, + data, + select: { + id: true, + username: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + isActive: true, + createdAt: true, + updatedAt: true, + }, + }); + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: req.user.id, + action: "admin_user_updated", + resource: `user:${updated.id}`, + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { updatedUserId: updated.id, fields: Object.keys(data) }, + }); + } + + res.json({ user: updated }); + } catch (error) { + console.error("Update user error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to update user", + }); + } +}); + +/** + * POST /auth/users/:id/reset-password + * Generate a temporary password for a user (admin-only). + * The user will be forced to set a new password on next sign-in. + */ +router.post("/users/:id/reset-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireAdmin(req, res)) return; + + // Avoid foot-guns while impersonating (admin actions should be from the real admin session). + if (req.user.impersonatorId) { + return res.status(403).json({ + error: "Forbidden", + message: "Password resets are not allowed while impersonating", + }); + } + + const userId = String(req.params.id || "").trim(); + if (!userId) { + return res.status(400).json({ error: "Bad request", message: "Invalid user id" }); + } + + if (userId === req.user.id) { + return res.status(409).json({ + error: "Conflict", + message: "Use Profile → Change Password for your own account", + }); + } + + const target = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + username: true, + role: true, + isActive: true, + }, + }); + + if (!target) { + return res.status(404).json({ error: "Not found", message: "User not found" }); + } + + const tempPassword = generateTempPassword(); + const saltRounds = 10; + const passwordHash = await bcrypt.hash(tempPassword, saltRounds); + + await prisma.user.update({ + where: { id: target.id }, + data: { + passwordHash, + mustResetPassword: true, + isActive: true, + }, + }); + + // Revoke refresh tokens (best-effort) to force re-login and/or block existing sessions. + try { + await prisma.refreshToken.updateMany({ + where: { userId: target.id, revoked: false }, + data: { revoked: true }, + }); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.debug("Refresh token revocation skipped (feature disabled or table missing)"); + } + } + + // Reset login rate limit for this identifier (best-effort). + try { + await ensureLoginAttemptLimiter(); + const key = `login:${target.email.toLowerCase()}`; + await loginAttemptLimiter?.resetKey(key); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.debug("Rate limit reset skipped:", error); + } + } + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: req.user.id, + action: "admin_password_reset_generated", + resource: `user:${target.id}`, + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { targetUserId: target.id, targetEmail: target.email }, + }); + } + + res.json({ + user: { id: target.id, email: target.email, username: target.username, role: target.role }, + tempPassword, + }); + } catch (error) { + console.error("Reset password error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to reset password", + }); + } +}); + +/** + * POST /auth/impersonate + * Generate tokens for another user (admin-only) + */ +router.post("/impersonate", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireAdmin(req, res)) return; + + const parsed = impersonateSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "Bad request", message: "Invalid impersonation payload" }); + } + + const target = + parsed.data.userId + ? await prisma.user.findUnique({ where: { id: parsed.data.userId } }) + : await findUserByIdentifier(parsed.data.identifier || ""); + + if (!target) { + return res.status(404).json({ error: "Not found", message: "User not found" }); + } + + if (!target.isActive) { + return res.status(403).json({ error: "Forbidden", message: "Target user is inactive" }); + } + + const { accessToken, refreshToken } = generateTokens(target.id, target.email, { + impersonatorId: req.user.id, + }); + + if (config.enableRefreshTokenRotation) { + const expiresAt = getRefreshTokenExpiresAt(); + try { + await prisma.refreshToken.create({ + data: { userId: target.id, token: refreshToken, expiresAt }, + }); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.debug("Refresh token storage skipped (feature disabled or table missing)"); + } + } + } + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: req.user.id, + action: "impersonation_started", + resource: `user:${target.id}`, + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { targetUserId: target.id }, + }); + } + + res.json({ + user: { + id: target.id, + username: target.username ?? null, + email: target.email, + name: target.name, + role: target.role, + mustResetPassword: target.mustResetPassword, + }, + accessToken, + refreshToken, + }); + } catch (error) { + console.error("Impersonation error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to impersonate user", + }); + } +}); + /** * POST /auth/password-reset-request * Request a password reset (sends reset token via email) @@ -929,7 +1637,7 @@ const passwordResetRequestSchema = z.object({ email: z.string().email().toLowerCase().trim(), }); -router.post("/password-reset-request", authRateLimiter, async (req: Request, res: Response) => { +router.post("/password-reset-request", loginAttemptRateLimiter, async (req: Request, res: Response) => { if (!(await ensureAuthEnabled(res))) return; // Check if password reset feature is enabled if (!config.enablePasswordReset) { @@ -1026,7 +1734,7 @@ const passwordResetConfirmSchema = z.object({ password: z.string().min(8).max(100), }); -router.post("/password-reset-confirm", authRateLimiter, async (req: Request, res: Response) => { +router.post("/password-reset-confirm", loginAttemptRateLimiter, async (req: Request, res: Response) => { if (!(await ensureAuthEnabled(res))) return; // Check if password reset feature is enabled if (!config.enablePasswordReset) { @@ -1144,6 +1852,12 @@ router.put("/profile", requireAuth, async (req: Request, res: Response) => { message: "User not authenticated", }); } + if (req.user.impersonatorId) { + return res.status(403).json({ + error: "Forbidden", + message: "Profile updates are not allowed while impersonating", + }); + } const parsed = updateProfileSchema.safeParse(req.body); @@ -1191,6 +1905,157 @@ router.put("/profile", requireAuth, async (req: Request, res: Response) => { } }); +/** + * PUT /auth/email + * Change email (requires current password) + */ +const updateEmailSchema = z.object({ + email: z.string().email().toLowerCase().trim(), + currentPassword: z.string().min(1).max(100), +}); + +router.put("/email", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!req.user) { + return res.status(401).json({ + error: "Unauthorized", + message: "User not authenticated", + }); + } + if (req.user.impersonatorId) { + return res.status(403).json({ + error: "Forbidden", + message: "Email changes are not allowed while impersonating", + }); + } + + const parsed = updateEmailSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: "Validation error", + message: "Invalid email update data", + }); + } + + const newEmail = parsed.data.email; + + const user = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { + id: true, + email: true, + passwordHash: true, + isActive: true, + }, + }); + + if (!user || !user.isActive) { + return res.status(401).json({ + error: "Unauthorized", + message: "User account not found or inactive", + }); + } + + if (!user.passwordHash) { + return res.status(400).json({ + error: "Bad request", + message: "Cannot change email for this account", + }); + } + + const passwordValid = await bcrypt.compare(parsed.data.currentPassword, user.passwordHash); + if (!passwordValid) { + return res.status(401).json({ + error: "Unauthorized", + message: "Current password is incorrect", + }); + } + + if (newEmail !== user.email) { + const existingUser = await prisma.user.findUnique({ + where: { email: newEmail }, + select: { id: true }, + }); + + if (existingUser && existingUser.id !== user.id) { + return res.status(409).json({ + error: "Conflict", + message: "User with this email already exists", + }); + } + } + + const previousEmail = user.email; + + const updatedUser = await prisma.user.update({ + where: { id: user.id }, + data: { email: newEmail }, + select: { + id: true, + username: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + createdAt: true, + updatedAt: true, + }, + }); + + // Revoke all refresh tokens for this user (force re-login) - if rotation enabled + if (config.enableRefreshTokenRotation) { + try { + await prisma.refreshToken.updateMany({ + where: { userId: updatedUser.id, revoked: false }, + data: { revoked: true }, + }); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.debug("Refresh token revocation skipped (feature disabled or table missing)"); + } + } + } + + const { accessToken, refreshToken } = generateTokens(updatedUser.id, updatedUser.email); + + if (config.enableRefreshTokenRotation) { + const expiresAt = getRefreshTokenExpiresAt(); + try { + await prisma.refreshToken.create({ + data: { userId: updatedUser.id, token: refreshToken, expiresAt }, + }); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.debug("Refresh token storage skipped (feature disabled or table missing)"); + } + } + } + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: updatedUser.id, + action: "email_updated", + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { previousEmail, newEmail: updatedUser.email }, + }); + } + + res.json({ + user: updatedUser, + accessToken, + refreshToken, + }); + } catch (error) { + console.error("Update email error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to update email", + }); + } +}); + /** * POST /auth/change-password * Change password (requires current password) @@ -1200,7 +2065,7 @@ const changePasswordSchema = z.object({ newPassword: z.string().min(8).max(100), }); -router.post("/change-password", requireAuth, authRateLimiter, async (req: Request, res: Response) => { +router.post("/change-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => { try { if (!(await ensureAuthEnabled(res))) return; if (!req.user) { @@ -1209,6 +2074,12 @@ router.post("/change-password", requireAuth, authRateLimiter, async (req: Reques message: "User not authenticated", }); } + if (req.user.impersonatorId) { + return res.status(403).json({ + error: "Forbidden", + message: "Password changes are not allowed while impersonating", + }); + } const parsed = changePasswordSchema.safeParse(req.body); @@ -1292,4 +2163,125 @@ router.post("/change-password", requireAuth, authRateLimiter, async (req: Reques } }); +/** + * POST /auth/must-reset-password + * Complete a forced password reset (only when mustResetPassword=true) + */ +const mustResetPasswordSchema = z.object({ + newPassword: z.string().min(8).max(100), +}); + +router.post("/must-reset-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!req.user) { + return res.status(401).json({ + error: "Unauthorized", + message: "User not authenticated", + }); + } + if (req.user.impersonatorId) { + return res.status(403).json({ + error: "Forbidden", + message: "Password changes are not allowed while impersonating", + }); + } + + const parsed = mustResetPasswordSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: "Validation error", + message: "Invalid password data", + }); + } + + const user = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { id: true, email: true, isActive: true, mustResetPassword: true }, + }); + + if (!user || !user.isActive) { + return res.status(401).json({ + error: "Unauthorized", + message: "User account not found or inactive", + }); + } + + if (!user.mustResetPassword) { + return res.status(409).json({ + error: "Conflict", + message: "Password reset is not required for this account", + }); + } + + const saltRounds = 10; + const passwordHash = await bcrypt.hash(parsed.data.newPassword, saltRounds); + + const updatedUser = await prisma.user.update({ + where: { id: user.id }, + data: { passwordHash, mustResetPassword: false }, + select: { + id: true, + username: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + createdAt: true, + updatedAt: true, + }, + }); + + // Revoke all refresh tokens for this user (force old sessions to re-auth) - if rotation enabled + if (config.enableRefreshTokenRotation) { + try { + await prisma.refreshToken.updateMany({ + where: { userId: updatedUser.id, revoked: false }, + data: { revoked: true }, + }); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.debug("Refresh token revocation skipped (feature disabled or table missing)"); + } + } + } + + const { accessToken, refreshToken } = generateTokens(updatedUser.id, updatedUser.email); + + if (config.enableRefreshTokenRotation) { + const expiresAt = getRefreshTokenExpiresAt(); + try { + await prisma.refreshToken.create({ + data: { userId: updatedUser.id, token: refreshToken, expiresAt }, + }); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.debug("Refresh token storage skipped (feature disabled or table missing)"); + } + } + } + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: updatedUser.id, + action: "password_reset_required_completed", + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + }); + } + + res.json({ + user: updatedUser, + accessToken, + refreshToken, + }); + } catch (error) { + console.error("Must reset password error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to reset password", + }); + } +}); + export default router; diff --git a/backend/src/index.ts b/backend/src/index.ts index cdc328c..81b86ae 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -8,6 +8,7 @@ import { Server } from "socket.io"; import { Worker } from "worker_threads"; import multer from "multer"; import archiver from "archiver"; +import JSZip from "jszip"; import { z } from "zod"; import helmet from "helmet"; import rateLimit from "express-rate-limit"; @@ -32,19 +33,8 @@ import authRouter from "./auth"; import { logAuditEvent } from "./utils/audit"; const backendRoot = path.resolve(__dirname, "../"); -const defaultDbPath = path.resolve(backendRoot, "prisma/dev.db"); console.log("Resolved DATABASE_URL:", process.env.DATABASE_URL); -// Helper to get the resolved database file path -const getResolvedDbPath = (): string => { - const dbUrl = process.env.DATABASE_URL || `file:${defaultDbPath}`; - if (dbUrl.startsWith("file:")) { - return dbUrl.replace(/^file:/, ""); - } - // Fallback to default for non-file URLs (e.g., Postgres) - return defaultDbPath; -}; - const normalizeOrigins = (rawOrigins?: string | null): string[] => { const fallback = "http://localhost:6767"; if (!rawOrigins || rawOrigins.trim().length === 0) { @@ -88,26 +78,38 @@ const isAllowedOrigin = (origin?: string): boolean => { const uploadDir = path.resolve(__dirname, "../uploads"); -const moveFile = async (source: string, destination: string) => { +let cachedBackendVersion: string | null = null; +const getBackendVersion = (): string => { + if (cachedBackendVersion) return cachedBackendVersion; try { - await fsPromises.rename(source, destination); - } catch (error) { - const err = error as NodeJS.ErrnoException; - if (!err || err.code !== "EXDEV") { - throw error; - } - - await fsPromises - .unlink(destination) - .catch((unlinkError: NodeJS.ErrnoException) => { - if (unlinkError && unlinkError.code !== "ENOENT") { - throw unlinkError; - } - }); - - await fsPromises.copyFile(source, destination); - await fsPromises.unlink(source); + const raw = fs.readFileSync(path.resolve(backendRoot, "package.json"), "utf8"); + const parsed = JSON.parse(raw) as { version?: string }; + cachedBackendVersion = typeof parsed.version === "string" ? parsed.version : "unknown"; + } catch { + cachedBackendVersion = "unknown"; } + return cachedBackendVersion; +}; + +const sanitizePathSegment = (input: string, fallback: string): string => { + const value = typeof input === "string" ? input.trim() : ""; + const cleaned = value + .replace(/[<>:"/\\|?*\x00-\x1F]/g, "_") + .replace(/\s+/g, " ") + .slice(0, 120) + .trim(); + return cleaned.length > 0 ? cleaned : fallback; +}; + +const makeUniqueName = (base: string, used: Set): string => { + let candidate = base; + let n = 2; + while (used.has(candidate)) { + candidate = `${base}__${n}`; + n += 1; + } + used.add(candidate); + return candidate; }; const initializeUploadDir = async () => { @@ -1306,78 +1308,118 @@ app.put("/library", requireAuth, asyncHandler(async (req, res, next) => { }); })); -app.get("/export", requireAuth, asyncHandler(async (req, res, next) => { +const excalidashManifestSchemaV1 = z.object({ + format: z.literal("excalidash"), + formatVersion: z.literal(1), + exportedAt: z.string().min(1), + excalidashBackendVersion: z.string().optional(), + userId: z.string().optional(), + unorganizedFolder: z.string().min(1), + collections: z.array( + z.object({ + id: z.string().min(1), + name: z.string(), + folder: z.string().min(1), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), + }) + ), + drawings: z.array( + z.object({ + id: z.string().min(1), + name: z.string(), + filePath: z.string().min(1), + collectionId: z.string().nullable(), + version: z.number().int().optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), + }) + ), +}); + +app.get("/export/excalidash", requireAuth, asyncHandler(async (req, res, next) => { if (!req.user) { return res.status(401).json({ error: "Unauthorized" }); } - // Export only user's data as JSON, not the entire database - const formatParam = - typeof req.query.format === "string" - ? req.query.format.toLowerCase() - : undefined; + const extParam = typeof req.query.ext === "string" ? req.query.ext.toLowerCase() : ""; + const zipSuffix = extParam === "zip"; + const date = new Date().toISOString().split("T")[0]; + const filename = zipSuffix + ? `excalidash-backup-${date}.excalidash.zip` + : `excalidash-backup-${date}.excalidash`; - if (formatParam === "db" || formatParam === "sqlite") { - // Database export should be admin-only, return 403 for regular users - return res.status(403).json({ - error: "Forbidden", - message: "Database export is not available", - }); - } + const exportedAt = new Date().toISOString(); - // Export user's drawings as JSON const drawings = await prisma.drawing.findMany({ - where: { - userId: req.user.id, - }, - include: { - collection: true, - }, + where: { userId: req.user.id }, + include: { collection: true }, }); - res.setHeader("Content-Type", "application/json"); - res.setHeader( - "Content-Disposition", - `attachment; filename="excalidash-export-${new Date().toISOString().split("T")[0]}.json"` - ); + const userCollections = await prisma.collection.findMany({ + where: { userId: req.user.id }, + }); - res.json({ - version: "1.0", - exportedAt: new Date().toISOString(), + const hasTrashDrawings = drawings.some((d) => d.collectionId === "trash"); + const collectionsToExport = [...userCollections]; + if (hasTrashDrawings && !collectionsToExport.some((c) => c.id === "trash")) { + const trash = await prisma.collection.findUnique({ where: { id: "trash" } }); + if (trash) collectionsToExport.push(trash); + } + + const exportSource = `${req.protocol}://${req.get("host")}`; + + const usedFolderNames = new Set(); + const unorganizedFolder = makeUniqueName("Unorganized", usedFolderNames); + + const folderByCollectionId = new Map(); + for (const collection of collectionsToExport) { + const base = sanitizePathSegment(collection.name, "Collection"); + const folder = makeUniqueName(base, usedFolderNames); + folderByCollectionId.set(collection.id, folder); + } + + type DrawingWithCollection = Prisma.DrawingGetPayload<{ + include: { collection: true }; + }>; + + const drawingsManifest = drawings.map((drawing: DrawingWithCollection) => { + const folder = drawing.collectionId + ? folderByCollectionId.get(drawing.collectionId) || unorganizedFolder + : unorganizedFolder; + const fileNameBase = sanitizePathSegment(drawing.name, "Untitled"); + const fileName = `${fileNameBase}__${drawing.id.slice(0, 8)}.excalidraw`; + const filePath = `${folder}/${fileName}`; + return { + id: drawing.id, + name: drawing.name, + filePath, + collectionId: drawing.collectionId ?? null, + version: drawing.version, + createdAt: drawing.createdAt.toISOString(), + updatedAt: drawing.updatedAt.toISOString(), + }; + }); + + const manifest = { + format: "excalidash" as const, + formatVersion: 1 as const, + exportedAt, + excalidashBackendVersion: getBackendVersion(), userId: req.user.id, - drawings: drawings.map((d: any) => ({ - id: d.id, - name: d.name, - elements: JSON.parse(d.elements), - appState: JSON.parse(d.appState), - files: JSON.parse(d.files || "{}"), - collectionId: d.collectionId, - collectionName: d.collection?.name || null, - createdAt: d.createdAt, - updatedAt: d.updatedAt, + unorganizedFolder, + collections: collectionsToExport.map((c) => ({ + id: c.id, + name: c.name, + folder: folderByCollectionId.get(c.id) || sanitizePathSegment(c.name, "Collection"), + createdAt: c.createdAt.toISOString(), + updatedAt: c.updatedAt.toISOString(), })), - }); -})); - -app.get("/export/json", requireAuth, asyncHandler(async (req, res, next) => { - if (!req.user) { - return res.status(401).json({ error: "Unauthorized" }); - } - - const drawings = await prisma.drawing.findMany({ - where: { - userId: req.user.id, - }, - include: { - collection: true, - }, - }); + drawings: drawingsManifest, + }; res.setHeader("Content-Type", "application/zip"); - res.setHeader( - "Content-Disposition", - `attachment; filename="excalidraw-drawings-${new Date().toISOString().split("T")[0]}.zip"`, - ); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); const archive = archiver("zip", { zlib: { level: 9 } }); @@ -1388,30 +1430,14 @@ app.get("/export/json", requireAuth, asyncHandler(async (req, res, next) => { archive.pipe(res); - type DrawingWithCollection = Prisma.DrawingGetPayload<{ - include: { collection: true }; - }>; + // Root manifest + archive.append(JSON.stringify(manifest, null, 2), { name: "excalidash.manifest.json" }); - type DrawingExportItem = { - name: string; - data: { - type: "excalidraw"; - version: 2; - source: string; - elements: unknown[]; - appState: Record; - files: Record; - }; - }; - - const drawingsByCollection: Record = {}; - const exportSource = `${req.protocol}://${req.get("host")}`; - - drawings.forEach((drawing: DrawingWithCollection) => { - const collectionName = drawing.collection?.name || "Unorganized"; - if (!drawingsByCollection[collectionName]) { - drawingsByCollection[collectionName] = []; - } + // Drawings organized by collection folder + const drawingsManifestById = new Map(drawingsManifest.map((d) => [d.id, d])); + for (const drawing of drawings) { + const meta = drawingsManifestById.get(drawing.id); + if (!meta) continue; const drawingData = { type: "excalidraw" as const, @@ -1420,142 +1446,641 @@ app.get("/export/json", requireAuth, asyncHandler(async (req, res, next) => { elements: JSON.parse(drawing.elements) as unknown[], appState: JSON.parse(drawing.appState) as Record, files: JSON.parse(drawing.files || "{}") as Record, + excalidash: { + drawingId: drawing.id, + collectionId: drawing.collectionId ?? null, + exportedAt, + }, }; - drawingsByCollection[collectionName].push({ - name: drawing.name, - data: drawingData, - }); - }); + archive.append(JSON.stringify(drawingData, null, 2), { name: meta.filePath }); + } - Object.entries(drawingsByCollection).forEach( - ([collectionName, collectionDrawings]) => { - const folderName = collectionName.replace(/[<>:"/\\|?*]/g, "_"); - collectionDrawings.forEach((drawing) => { - const fileName = `${drawing.name.replace(/[<>:"/\\|?*]/g, "_")}.excalidraw`; - const filePath = `${folderName}/${fileName}`; + const readme = `ExcaliDash Backup (.excalidash) - archive.append(JSON.stringify(drawing.data, null, 2), { - name: filePath, - }); - }); - }, - ); +This file is a zip archive containing a versioned ExcaliDash manifest and your drawings, +organized into folders by collection. - const readmeContent = `ExcaliDash Export +Files: +- excalidash.manifest.json (required) +- /*.excalidraw -This archive contains your ExcaliDash drawings organized by collection folders. - -Structure: -- Each collection has its own folder -- Each drawing is saved as a .excalidraw file -- Files can be imported back into ExcaliDash - -Export Date: ${new Date().toISOString()} -Total Collections: ${Object.keys(drawingsByCollection).length} -Total Drawings: ${drawings.length} - -Collections: -${Object.entries(drawingsByCollection) - .map(([name, drawings]) => `- ${name}: ${drawings.length} drawings`) - .join("\n")} +ExportedAt: ${exportedAt} +FormatVersion: 1 +BackendVersion: ${getBackendVersion()} +Collections: ${collectionsToExport.length} +Drawings: ${drawings.length} `; - archive.append(readmeContent, { name: "README.txt" }); + archive.append(readme, { name: "README.txt" }); await archive.finalize(); })); -// Database import endpoints should be admin-only or disabled in production -// For now, we'll require auth but note that full DB import is dangerous -app.post("/import/sqlite/verify", requireAuth, upload.single("db"), asyncHandler(async (req, res, next) => { +app.post("/import/excalidash/verify", requireAuth, upload.single("archive"), asyncHandler(async (req, res, next) => { if (!req.user) { return res.status(401).json({ error: "Unauthorized" }); } - // Database import is dangerous - consider disabling in production - if (config.nodeEnv === "production") { - return res.status(403).json({ - error: "Forbidden", - message: "Database import is disabled in production", - }); - } - if (!req.file) { return res.status(400).json({ error: "No file uploaded" }); } const stagedPath = req.file.path; - const isValid = await verifyDatabaseIntegrityAsync(stagedPath); - await removeFileIfExists(stagedPath); + try { + const buffer = await fsPromises.readFile(stagedPath); + const zip = await JSZip.loadAsync(buffer); + const manifestFile = zip.file("excalidash.manifest.json"); + if (!manifestFile) { + return res.status(400).json({ + error: "Invalid backup", + message: "Missing excalidash.manifest.json", + }); + } + const rawManifest = await manifestFile.async("string"); + let manifestJson: unknown; + try { + manifestJson = JSON.parse(rawManifest); + } catch { + return res.status(400).json({ + error: "Invalid backup manifest", + message: "excalidash.manifest.json is not valid JSON", + }); + } + const parsed = excalidashManifestSchemaV1.safeParse(manifestJson); + if (!parsed.success) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: "Malformed excalidash.manifest.json", + }); + } - if (!isValid) { - return res.status(400).json({ error: "Invalid database format" }); + const manifest = parsed.data; + res.json({ + valid: true, + formatVersion: manifest.formatVersion, + exportedAt: manifest.exportedAt, + excalidashBackendVersion: manifest.excalidashBackendVersion || null, + collections: manifest.collections.length, + drawings: manifest.drawings.length, + }); + } finally { + await removeFileIfExists(stagedPath); } - - res.json({ valid: true, message: "Database file is valid" }); })); -app.post("/import/sqlite", requireAuth, upload.single("db"), asyncHandler(async (req, res, next) => { +app.post("/import/excalidash", requireAuth, upload.single("archive"), asyncHandler(async (req, res, next) => { if (!req.user) { return res.status(401).json({ error: "Unauthorized" }); } - // Database import is dangerous - consider disabling in production - if (config.nodeEnv === "production") { - return res.status(403).json({ - error: "Forbidden", - message: "Database import is disabled in production", - }); - } - if (!req.file) { return res.status(400).json({ error: "No file uploaded" }); } - const originalPath = req.file.path; - const stagedPath = path.join( - uploadDir, - `temp-${Date.now()}-${Math.random().toString(36).slice(2)}.db` - ); - + const stagedPath = req.file.path; try { - await moveFile(originalPath, stagedPath); - } catch (error) { - console.error("Failed to stage uploaded database", error); - await removeFileIfExists(originalPath); - await removeFileIfExists(stagedPath); - return res.status(500).json({ error: "Failed to stage uploaded file" }); - } + const buffer = await fsPromises.readFile(stagedPath); + const zip = await JSZip.loadAsync(buffer); + const manifestFile = zip.file("excalidash.manifest.json"); + if (!manifestFile) { + return res.status(400).json({ + error: "Invalid backup", + message: "Missing excalidash.manifest.json", + }); + } - const isValid = await verifyDatabaseIntegrityAsync(stagedPath); - if (!isValid) { - await removeFileIfExists(stagedPath); - return res - .status(400) - .json({ error: "Uploaded database failed integrity check" }); - } - - const dbPath = getResolvedDbPath(); - const backupPath = `${dbPath}.backup`; - - try { + const rawManifest = await manifestFile.async("string"); + let manifestJson: unknown; try { - await fsPromises.access(dbPath); - await fsPromises.copyFile(dbPath, backupPath); - } catch { } + manifestJson = JSON.parse(rawManifest); + } catch { + return res.status(400).json({ + error: "Invalid backup manifest", + message: "excalidash.manifest.json is not valid JSON", + }); + } + const parsed = excalidashManifestSchemaV1.safeParse(manifestJson); + if (!parsed.success) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: "Malformed excalidash.manifest.json", + }); + } - await moveFile(stagedPath, dbPath); - } catch (error) { - console.error("Failed to replace database", error); + const manifest = parsed.data; + + const collectionIdMap = new Map(); + let collectionsCreated = 0; + let collectionsUpdated = 0; + let collectionIdConflicts = 0; + + for (const c of manifest.collections) { + if (c.id === "trash") { + collectionIdMap.set("trash", "trash"); + continue; + } + + const existing = await prisma.collection.findUnique({ where: { id: c.id } }); + if (!existing) { + await prisma.collection.create({ + data: { + id: c.id, + name: c.name, + userId: req.user.id, + }, + }); + collectionIdMap.set(c.id, c.id); + collectionsCreated += 1; + continue; + } + + if (existing.userId === req.user.id) { + await prisma.collection.update({ + where: { id: c.id }, + data: { name: c.name }, + }); + collectionIdMap.set(c.id, c.id); + collectionsUpdated += 1; + continue; + } + + const newId = uuidv4(); + await prisma.collection.create({ + data: { + id: newId, + name: c.name, + userId: req.user.id, + }, + }); + collectionIdMap.set(c.id, newId); + collectionsCreated += 1; + collectionIdConflicts += 1; + } + + const resolveCollectionId = async (collectionId: string | null): Promise => { + if (!collectionId) return null; + if (collectionId === "trash") { + await ensureTrashCollection(req.user!.id); + return "trash"; + } + return collectionIdMap.get(collectionId) || null; + }; + + let drawingsCreated = 0; + let drawingsUpdated = 0; + let drawingIdConflicts = 0; + + for (const d of manifest.drawings) { + const entry = zip.file(d.filePath); + if (!entry) { + return res.status(400).json({ + error: "Invalid backup", + message: `Missing drawing file: ${d.filePath}`, + }); + } + + const raw = await entry.async("string"); + const parsedJson = JSON.parse(raw) as any; + + const elements = Array.isArray(parsedJson?.elements) ? parsedJson.elements : []; + const appState = typeof parsedJson?.appState === "object" && parsedJson.appState !== null ? parsedJson.appState : {}; + const files = typeof parsedJson?.files === "object" && parsedJson.files !== null ? parsedJson.files : {}; + + const imported = { + name: d.name, + elements, + appState, + files, + preview: null as string | null, + collectionId: await resolveCollectionId(d.collectionId), + }; + + if (!validateImportedDrawing(imported)) { + return res.status(400).json({ + error: "Invalid imported drawing", + message: `Drawing failed validation: ${d.filePath}`, + }); + } + + const sanitized = sanitizeDrawingData(imported); + const targetCollectionId = imported.collectionId; + + const existing = await prisma.drawing.findUnique({ where: { id: d.id } }); + if (!existing) { + await prisma.drawing.create({ + data: { + id: d.id, + name: sanitizeText(imported.name, 255) || "Untitled Drawing", + elements: JSON.stringify(sanitized.elements), + appState: JSON.stringify(sanitized.appState), + files: JSON.stringify(sanitized.files || {}), + preview: sanitized.preview ?? null, + version: typeof d.version === "number" ? d.version : 1, + userId: req.user.id, + collectionId: targetCollectionId, + }, + }); + drawingsCreated += 1; + continue; + } + + if (existing.userId === req.user.id) { + await prisma.drawing.update({ + where: { id: d.id }, + data: { + name: sanitizeText(imported.name, 255) || "Untitled Drawing", + elements: JSON.stringify(sanitized.elements), + appState: JSON.stringify(sanitized.appState), + files: JSON.stringify(sanitized.files || {}), + preview: sanitized.preview ?? null, + version: typeof d.version === "number" ? d.version : existing.version, + collectionId: targetCollectionId, + }, + }); + drawingsUpdated += 1; + continue; + } + + const newId = uuidv4(); + await prisma.drawing.create({ + data: { + id: newId, + name: sanitizeText(imported.name, 255) || "Untitled Drawing", + elements: JSON.stringify(sanitized.elements), + appState: JSON.stringify(sanitized.appState), + files: JSON.stringify(sanitized.files || {}), + preview: sanitized.preview ?? null, + version: typeof d.version === "number" ? d.version : 1, + userId: req.user.id, + collectionId: targetCollectionId, + }, + }); + drawingsCreated += 1; + drawingIdConflicts += 1; + } + + invalidateDrawingsCache(); + + res.json({ + success: true, + message: "Backup imported successfully", + collections: { + created: collectionsCreated, + updated: collectionsUpdated, + idConflicts: collectionIdConflicts, + }, + drawings: { + created: drawingsCreated, + updated: drawingsUpdated, + idConflicts: drawingIdConflicts, + }, + }); + } finally { await removeFileIfExists(stagedPath); - return res.status(500).json({ error: "Failed to replace database" }); + } +})); + +const findSqliteTable = (tables: string[], candidates: string[]): string | null => { + const byLower = new Map(tables.map((t) => [t.toLowerCase(), t])); + for (const candidate of candidates) { + const found = byLower.get(candidate.toLowerCase()); + if (found) return found; + } + return null; +}; + +const parseOptionalJson = (raw: unknown, fallback: T): T => { + if (typeof raw === "string") { + try { + return JSON.parse(raw) as T; + } catch { + return fallback; + } + } + if (typeof raw === "object" && raw !== null) { + return raw as T; + } + return fallback; +}; + +const getCurrentLatestPrismaMigrationName = async (): Promise => { + try { + const migrationsDir = path.resolve(backendRoot, "prisma/migrations"); + const entries = await fsPromises.readdir(migrationsDir, { withFileTypes: true }); + const dirs = entries + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .filter((name) => !name.startsWith(".")); + if (dirs.length === 0) return null; + // Migration folders start with timestamps, so lexicographic max is newest. + dirs.sort(); + return dirs[dirs.length - 1] || null; + } catch { + return null; + } +}; + +/** + * Legacy SQLite import (MERGE) - does not overwrite the current DB. + * This is safer than /import/sqlite which replaces the entire database file. + */ +app.post("/import/sqlite/legacy/verify", requireAuth, upload.single("db"), asyncHandler(async (req, res, next) => { + if (!req.user) { + return res.status(401).json({ error: "Unauthorized" }); } - await prisma.$disconnect(); - invalidateDrawingsCache(); + if (!req.file) { + return res.status(400).json({ error: "No file uploaded" }); + } - res.json({ success: true, message: "Database imported successfully" }); + const stagedPath = req.file.path; + try { + const isValid = await verifyDatabaseIntegrityAsync(stagedPath); + if (!isValid) { + return res.status(400).json({ error: "Invalid database format" }); + } + + // Use better-sqlite3 to inspect the legacy DB file + let Database: any; + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + Database = require("better-sqlite3") as any; + } catch (error) { + return res.status(500).json({ + error: "Legacy DB support unavailable", + message: + "Failed to load better-sqlite3. Run `cd backend && npm rebuild better-sqlite3` (or reinstall dependencies) and try again.", + }); + } + const db = new Database(stagedPath, { readonly: true, fileMustExist: true }); + try { + const tables: string[] = db + .prepare("SELECT name FROM sqlite_master WHERE type='table'") + .all() + .map((row: any) => String(row.name)); + + const drawingTable = findSqliteTable(tables, ["Drawing", "drawings"]); + const collectionTable = findSqliteTable(tables, ["Collection", "collections"]); + if (!drawingTable) { + return res.status(400).json({ error: "Invalid legacy DB", message: "Missing Drawing table" }); + } + + const drawingsCount = Number( + db.prepare(`SELECT COUNT(1) as c FROM "${drawingTable}"`).get()?.c ?? 0 + ); + const collectionsCount = collectionTable + ? Number(db.prepare(`SELECT COUNT(1) as c FROM "${collectionTable}"`).get()?.c ?? 0) + : 0; + + let latestMigration: string | null = null; + const migrationsTable = findSqliteTable(tables, ["_prisma_migrations"]); + if (migrationsTable) { + try { + const row = db + .prepare( + `SELECT migration_name as name, finished_at as finishedAt FROM "${migrationsTable}" ORDER BY finished_at DESC LIMIT 1` + ) + .get(); + if (row?.name) latestMigration = String(row.name); + } catch { + latestMigration = null; + } + } + + res.json({ + valid: true, + drawings: drawingsCount, + collections: collectionsCount, + latestMigration, + currentLatestMigration: await getCurrentLatestPrismaMigrationName(), + }); + } finally { + try { + db.close(); + } catch { } + } + } finally { + await removeFileIfExists(stagedPath); + } +})); + +app.post("/import/sqlite/legacy", requireAuth, upload.single("db"), asyncHandler(async (req, res, next) => { + if (!req.user) { + return res.status(401).json({ error: "Unauthorized" }); + } + + if (!req.file) { + return res.status(400).json({ error: "No file uploaded" }); + } + + const stagedPath = req.file.path; + try { + const isValid = await verifyDatabaseIntegrityAsync(stagedPath); + if (!isValid) { + return res.status(400).json({ error: "Invalid database format" }); + } + + let Database: any; + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + Database = require("better-sqlite3") as any; + } catch (error) { + return res.status(500).json({ + error: "Legacy DB support unavailable", + message: + "Failed to load better-sqlite3. Run `cd backend && npm rebuild better-sqlite3` (or reinstall dependencies) and try again.", + }); + } + const legacyDb = new Database(stagedPath, { readonly: true, fileMustExist: true }); + try { + const tables: string[] = legacyDb + .prepare("SELECT name FROM sqlite_master WHERE type='table'") + .all() + .map((row: any) => String(row.name)); + + const drawingTable = findSqliteTable(tables, ["Drawing", "drawings"]); + const collectionTable = findSqliteTable(tables, ["Collection", "collections"]); + + if (!drawingTable) { + return res.status(400).json({ error: "Invalid legacy DB", message: "Missing Drawing table" }); + } + + const importedCollections: any[] = collectionTable + ? legacyDb.prepare(`SELECT * FROM "${collectionTable}"`).all() + : []; + const importedDrawings: any[] = legacyDb.prepare(`SELECT * FROM "${drawingTable}"`).all(); + + const hasTrash = importedDrawings.some((d) => String(d.collectionId || "") === "trash"); + if (hasTrash) { + await ensureTrashCollection(req.user.id); + } + + const collectionIdMap = new Map(); + let collectionsCreated = 0; + let collectionsUpdated = 0; + let collectionIdConflicts = 0; + + for (const c of importedCollections) { + const importedId = typeof c.id === "string" ? c.id : null; + const name = typeof c.name === "string" ? c.name : "Collection"; + + if (importedId === "trash" || name === "Trash") { + collectionIdMap.set(importedId || "trash", "trash"); + continue; + } + + if (!importedId) { + const newId = uuidv4(); + await prisma.collection.create({ + data: { id: newId, name: sanitizeText(name, 100) || "Collection", userId: req.user.id }, + }); + collectionIdMap.set(`__name:${name}`, newId); + collectionsCreated += 1; + continue; + } + + const existing = await prisma.collection.findUnique({ where: { id: importedId } }); + if (!existing) { + await prisma.collection.create({ + data: { id: importedId, name: sanitizeText(name, 100) || "Collection", userId: req.user.id }, + }); + collectionIdMap.set(importedId, importedId); + collectionsCreated += 1; + continue; + } + + if (existing.userId === req.user.id) { + await prisma.collection.update({ + where: { id: importedId }, + data: { name: sanitizeText(name, 100) || "Collection" }, + }); + collectionIdMap.set(importedId, importedId); + collectionsUpdated += 1; + continue; + } + + const newId = uuidv4(); + await prisma.collection.create({ + data: { id: newId, name: sanitizeText(name, 100) || "Collection", userId: req.user.id }, + }); + collectionIdMap.set(importedId, newId); + collectionsCreated += 1; + collectionIdConflicts += 1; + } + + const resolveImportedCollectionId = (rawCollectionId: unknown, rawCollectionName: unknown): string | null => { + const id = typeof rawCollectionId === "string" ? rawCollectionId : null; + const name = typeof rawCollectionName === "string" ? rawCollectionName : null; + + if (id === "trash" || name === "Trash") return "trash"; + if (id && collectionIdMap.has(id)) return collectionIdMap.get(id)!; + if (name && collectionIdMap.has(`__name:${name}`)) return collectionIdMap.get(`__name:${name}`)!; + return null; + }; + + let drawingsCreated = 0; + let drawingsUpdated = 0; + let drawingIdConflicts = 0; + + for (const d of importedDrawings) { + const importedId = typeof d.id === "string" ? d.id : null; + + const elements = parseOptionalJson(d.elements, []); + const appState = parseOptionalJson>(d.appState, {}); + const files = parseOptionalJson>(d.files, {}); + const preview = typeof d.preview === "string" ? d.preview : null; + + const importPayload = { + name: typeof d.name === "string" ? d.name : "Untitled Drawing", + elements, + appState, + files, + preview, + collectionId: resolveImportedCollectionId(d.collectionId, d.collectionName), + }; + + if (!validateImportedDrawing(importPayload)) { + return res.status(400).json({ + error: "Invalid imported drawing", + message: "Legacy database contains invalid drawing data", + }); + } + + const sanitized = sanitizeDrawingData(importPayload); + const drawingName = sanitizeText(importPayload.name, 255) || "Untitled Drawing"; + + const existing = importedId ? await prisma.drawing.findUnique({ where: { id: importedId } }) : null; + + if (!existing) { + const idToUse = importedId || uuidv4(); + await prisma.drawing.create({ + data: { + id: idToUse, + name: drawingName, + elements: JSON.stringify(sanitized.elements), + appState: JSON.stringify(sanitized.appState), + files: JSON.stringify(sanitized.files || {}), + preview: sanitized.preview ?? null, + version: Number.isFinite(Number(d.version)) ? Number(d.version) : 1, + userId: req.user.id, + collectionId: importPayload.collectionId ?? null, + }, + }); + drawingsCreated += 1; + continue; + } + + if (existing.userId === req.user.id) { + await prisma.drawing.update({ + where: { id: existing.id }, + data: { + name: drawingName, + elements: JSON.stringify(sanitized.elements), + appState: JSON.stringify(sanitized.appState), + files: JSON.stringify(sanitized.files || {}), + preview: sanitized.preview ?? null, + version: Number.isFinite(Number(d.version)) ? Number(d.version) : existing.version, + collectionId: importPayload.collectionId ?? null, + }, + }); + drawingsUpdated += 1; + continue; + } + + const newId = uuidv4(); + await prisma.drawing.create({ + data: { + id: newId, + name: drawingName, + elements: JSON.stringify(sanitized.elements), + appState: JSON.stringify(sanitized.appState), + files: JSON.stringify(sanitized.files || {}), + preview: sanitized.preview ?? null, + version: Number.isFinite(Number(d.version)) ? Number(d.version) : 1, + userId: req.user.id, + collectionId: importPayload.collectionId ?? null, + }, + }); + drawingsCreated += 1; + drawingIdConflicts += 1; + } + + invalidateDrawingsCache(); + + res.json({ + success: true, + collections: { created: collectionsCreated, updated: collectionsUpdated, idConflicts: collectionIdConflicts }, + drawings: { created: drawingsCreated, updated: drawingsUpdated, idConflicts: drawingIdConflicts }, + }); + } finally { + try { + legacyDb.close(); + } catch { } + } + } finally { + await removeFileIfExists(stagedPath); + } })); // Error handler middleware (must be last) diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 721e510..5fd551c 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -89,6 +89,7 @@ declare global { name: string; role: string; mustResetPassword?: boolean; + impersonatorId?: string; }; } } @@ -98,6 +99,7 @@ interface JwtPayload { userId: string; email: string; type: "access" | "refresh"; + impersonatorId?: string; } /** @@ -108,10 +110,13 @@ const isJwtPayload = (decoded: unknown): decoded is JwtPayload => { return false; } const payload = decoded as Record; + const impersonatorOk = + typeof payload.impersonatorId === "undefined" || typeof payload.impersonatorId === "string"; return ( typeof payload.userId === "string" && typeof payload.email === "string" && - (payload.type === "access" || payload.type === "refresh") + (payload.type === "access" || payload.type === "refresh") && + impersonatorOk ); }; @@ -148,6 +153,23 @@ const verifyToken = (token: string): JwtPayload | null => { } }; +const normalizeRequestPath = (req: Request): string => { + const raw = (req.originalUrl || req.url || "").split("?")[0] || ""; + // In some deployments the backend may see a /api prefix. + return raw.replace(/^\/api(?=\/)/, ""); +}; + +const isAllowedWhileMustResetPassword = (req: Request): boolean => { + const path = normalizeRequestPath(req); + + // Permit fetching current user and changing password. + if (req.method === "GET" && path === "/auth/me") return true; + if (req.method === "POST" && path === "/auth/change-password") return true; + if (req.method === "POST" && path === "/auth/must-reset-password") return true; + + return false; +}; + /** * Require authentication middleware * Protects routes that require a valid JWT token @@ -224,6 +246,15 @@ export const requireAuth = async ( return; } + if (user.mustResetPassword && !isAllowedWhileMustResetPassword(req)) { + res.status(403).json({ + error: "Forbidden", + code: "MUST_RESET_PASSWORD", + message: "You must reset your password before using the app", + }); + return; + } + // Attach user to request req.user = { id: user.id, @@ -232,6 +263,7 @@ export const requireAuth = async ( name: user.name, role: user.role, mustResetPassword: user.mustResetPassword, + impersonatorId: payload.impersonatorId, }; next(); @@ -297,6 +329,7 @@ export const optionalAuth = async ( name: user.name, role: user.role, mustResetPassword: user.mustResetPassword, + impersonatorId: payload.impersonatorId, }; } } catch (error) { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dcfec97..d51aab1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { Dashboard } from './pages/Dashboard'; import { Editor } from './pages/Editor'; import { Settings } from './pages/Settings'; import { Profile } from './pages/Profile'; +import { Admin } from './pages/Admin'; import { Login } from './pages/Login'; import { Register } from './pages/Register'; import { PasswordResetRequest } from './pages/PasswordResetRequest'; @@ -55,6 +56,14 @@ function App() { } /> + + + + } + /> response, async (error) => { + // Handle must-reset-password enforcement (403) + if ( + error.response?.status === 403 && + error.response?.data?.code === "MUST_RESET_PASSWORD" + ) { + const url = String(error.config?.url || ""); + const isAuthRoute = + url.startsWith("/auth/me") || + url.startsWith("/auth/must-reset-password") || + url.startsWith("/auth/login") || + url.startsWith("/auth/register"); + + if (!isAuthRoute && window.location.pathname !== "/login") { + window.location.href = "/login?mustReset=1"; + } + return Promise.reject(error); + } + // Handle 401 Unauthorized (invalid/expired JWT) if (error.response?.status === 401) { const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY); diff --git a/frontend/src/components/ConfirmModal.tsx b/frontend/src/components/ConfirmModal.tsx index 0a1fd85..650b129 100644 --- a/frontend/src/components/ConfirmModal.tsx +++ b/frontend/src/components/ConfirmModal.tsx @@ -5,7 +5,7 @@ import { AlertTriangle, CheckCircle, X } from 'lucide-react'; interface ConfirmModalProps { isOpen: boolean; title: string; - message: string; + message: React.ReactNode; confirmText?: string; cancelText?: string; onConfirm: () => void; @@ -60,9 +60,9 @@ export const ConfirmModal: React.FC = ({

{title}

-

+

{message} -

+
diff --git a/frontend/src/components/FingerprintAvatar.tsx b/frontend/src/components/FingerprintAvatar.tsx new file mode 100644 index 0000000..92ea1cb --- /dev/null +++ b/frontend/src/components/FingerprintAvatar.tsx @@ -0,0 +1,125 @@ +import React, { useMemo, useState } from 'react'; + +const DEVICE_ID_KEY = 'excalidash-device-id'; + +const getOrCreateDeviceId = (): string => { + if (typeof window === 'undefined') return 'server'; + const existing = localStorage.getItem(DEVICE_ID_KEY); + if (existing) return existing; + + const generated = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; + + localStorage.setItem(DEVICE_ID_KEY, generated); + return generated; +}; + +const fnv1a = (input: string): number => { + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i += 1) { + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +}; + +const toHsl = (n: number) => { + const hue = n % 360; + const sat = 60 + (n % 20); + const light = 45 + (n % 10); + return `hsl(${hue} ${sat}% ${light}%)`; +}; + +const buildPattern = (seed: string) => { + let x = fnv1a(seed); + const nextBit = () => { + // xorshift32 + x ^= x << 13; + x ^= x >>> 17; + x ^= x << 5; + return (x >>> 0) & 1; + }; + + const cells: boolean[][] = Array.from({ length: 5 }, () => Array.from({ length: 5 }, () => false)); + + // Generate left 3 columns, mirror to 5. + for (let row = 0; row < 5; row += 1) { + for (let col = 0; col < 3; col += 1) { + const on = nextBit() === 1; + cells[row][col] = on; + cells[row][4 - col] = on; + } + } + + const foreground = toHsl(x); + const background = 'hsl(0 0% 98%)'; + const backgroundDark = 'hsl(0 0% 12%)'; + + return { cells, foreground, background, backgroundDark }; +}; + +export const FingerprintAvatar: React.FC<{ + size?: number; + seed?: string; + title?: string; + className?: string; +}> = ({ size = 32, seed, title = 'Device fingerprint', className }) => { + const [deviceId] = useState(() => getOrCreateDeviceId()); + const effectiveSeed = seed || deviceId; + + const { cells, foreground, background, backgroundDark } = useMemo( + () => buildPattern(effectiveSeed), + [effectiveSeed] + ); + + const padding = 0.5; + const viewBox = `${-padding} ${-padding} ${5 + padding * 2} ${5 + padding * 2}`; + + return ( + + {title} + + + {cells.map((row, r) => + row.map((on, c) => + on ? : null + ) + )} + + + ); +}; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index b990797..9cf2b5f 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,7 +1,10 @@ import React, { useState, useRef, useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; +import { Menu, X } from 'lucide-react'; import { Sidebar } from './Sidebar'; import { UploadStatus } from './UploadStatus'; import type { Collection } from '../types'; +import clsx from 'clsx'; interface LayoutProps { children: React.ReactNode; @@ -24,8 +27,11 @@ export const Layout: React.FC = ({ onDeleteCollection, onDrop }) => { + const location = useLocation(); const [sidebarWidth, setSidebarWidth] = useState(260); const [isResizing, setIsResizing] = useState(false); + const [isMobile, setIsMobile] = useState(false); + const [isSidebarOpen, setIsSidebarOpen] = useState(true); const sidebarRef = useRef(null); const startXRef = useRef(0); const startWidthRef = useRef(0); @@ -61,39 +67,115 @@ export const Layout: React.FC = ({ }; }, []); + useEffect(() => { + const mq = window.matchMedia('(max-width: 1024px)'); + const sync = () => { + setIsMobile(mq.matches); + setIsSidebarOpen(!mq.matches); + }; + + sync(); + mq.addEventListener('change', sync); + return () => mq.removeEventListener('change', sync); + }, []); + + useEffect(() => { + if (!isMobile) return; + setIsSidebarOpen(false); + }, [isMobile, location.pathname, location.search]); + return ( -
-
- +
+ ) : ( +
+ +
+
+ {children} +
+
+
+ )}
); diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx index 06c8cc5..dcc6a4f 100644 --- a/frontend/src/components/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Navigate } from 'react-router-dom'; +import { Navigate, useLocation } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; interface ProtectedRouteProps { @@ -7,7 +7,8 @@ interface ProtectedRouteProps { } export const ProtectedRoute: React.FC = ({ children }) => { - const { isAuthenticated, loading, authEnabled, bootstrapRequired } = useAuth(); + const location = useLocation(); + const { isAuthenticated, loading, authEnabled, bootstrapRequired, user } = useAuth(); if (loading || authEnabled === null) { return ( @@ -30,5 +31,10 @@ export const ProtectedRoute: React.FC = ({ children }) => { return ; } + // Force password reset before allowing app access. + if (user?.mustResetPassword && location.pathname !== '/login') { + return ; + } + return <>{children}; }; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index be79bff..889625d 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,11 +1,13 @@ 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 } from 'lucide-react'; +import { LayoutGrid, Folder, Plus, Trash2, Edit2, Archive, FolderOpen, Settings as SettingsIcon, User, LogOut, Shield } from 'lucide-react'; import type { Collection } from '../types'; import clsx from 'clsx'; import { ConfirmModal } from './ConfirmModal'; import { Logo } from './Logo'; import { useAuth } from '../context/AuthContext'; +import { FingerprintAvatar } from './FingerprintAvatar'; +import { readImpersonationState, stopImpersonation as restoreImpersonation, type ImpersonationState } from '../utils/impersonation'; interface SidebarProps { collections: Collection[]; @@ -123,6 +125,8 @@ export const Sidebar: React.FC = ({ }) => { const navigate = useNavigate(); const { logout, user, authEnabled } = useAuth(); + const isAdmin = user?.role === 'ADMIN'; + const [impersonation, setImpersonation] = useState(null); const [isCreating, setIsCreating] = useState(false); const [newCollectionName, setNewCollectionName] = useState(''); const [editingId, setEditingId] = useState(null); @@ -137,6 +141,17 @@ export const Sidebar: React.FC = ({ return () => document.removeEventListener('click', handleClickOutside); }, []); + useEffect(() => { + if (!authEnabled) { + setImpersonation(null); + return; + } + const sync = () => setImpersonation(readImpersonationState()); + sync(); + window.addEventListener('storage', sync); + return () => window.removeEventListener('storage', sync); + }, [authEnabled]); + const handleCreateSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -169,7 +184,7 @@ export const Sidebar: React.FC = ({ return ( <>
-
+

ExcaliDash @@ -178,7 +193,7 @@ export const Sidebar: React.FC = ({

-
+
)} + {authEnabled && isAdmin && ( + + )} + +
+
+ )} {user && (
-
{user.name}
-
{user.email}
+
+ + +
+
{user.name}
+
{user.email}
+
+
)} + +
+
+ + {impersonation && ( +
+
+
+ + Impersonating {impersonation.target.email} +
+
+ Stop impersonation to return to {impersonation.impersonator.email}. +
+
+ +
+ )} + + {success && ( +
+

{success}

+
+ )} + {error && ( +
+

{error}

+
+ )} + + {createOpen && ( +
+
+
+ +
+

Create User

+
+ +
+
+ + setCreateEmail(e.target.value)} + required + className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-slate-200 dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white outline-none" + /> +
+ +
+ + setCreateName(e.target.value)} + required + className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-slate-200 dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white outline-none" + /> +
+ +
+ + setCreateUsername(e.target.value)} + className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-slate-200 dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white outline-none" + /> +
+ +
+ + setCreatePassword(e.target.value)} + minLength={8} + required + className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-slate-200 dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white outline-none" + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ )} + +
+
+
+ +
+
+

Login Rate Limiting

+

+ Reduce brute-force attacks; disable only for trusted environments. +

+
+ {loginRateLimitLoading && ( + Loading… + )} +
+ +
+
+ +
+
+ + setLoginRateLimitWindowMinutes(Number(e.target.value))} + className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-slate-200 dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white outline-none" + /> +
+
+ + setLoginRateLimitMax(Number(e.target.value))} + className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-slate-200 dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white outline-none" + /> +
+
+ +
+
+ + setResetIdentifier(e.target.value)} + placeholder="user@example.com" + className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-slate-200 dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white outline-none" + /> + + {users.map(u => ( + +
+
+ + +
+
+
+ +
+
+
+ +
+

Users

+ {loadingUsers && Loading…} +
+ +
+ + + + + + + + + + + + {users.map(u => ( + + + + + + + + ))} + {users.length === 0 && !loadingUsers && ( + + + + )} + +
UserRoleActiveMust ResetActions
+
{u.name}
+
{u.email}
+ {u.username &&
@{u.username}
} +
+ + + + + + +
+ + +
+
+ No users found. +
+
+
+ + { + if (impersonateTarget) { + void startImpersonation(impersonateTarget); + } + setImpersonateTarget(null); + }} + onCancel={() => setImpersonateTarget(null)} + /> + + +
+ Temporary password for {resetPasswordResult.email}. They will be prompted to set a new password after signing in. +
+
+ {resetPasswordResult.tempPassword} +
+
+ ) : ( + '' + ) + } + confirmText="Copy" + cancelText="Close" + isDangerous={false} + variant="success" + onConfirm={() => { + if (!resetPasswordResult) return; + void navigator.clipboard?.writeText(resetPasswordResult.tempPassword); + setResetPasswordResult(null); + }} + onCancel={() => setResetPasswordResult(null)} + /> + + ); +}; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index c7f976e..84c4f52 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -674,12 +674,12 @@ export const Dashboard: React.FC = () => { )} -

+

{viewTitle}

-
+
{
-
+
+ + {mustReset && ( +
+ +
+ )}
diff --git a/frontend/src/pages/PasswordResetRequest.tsx b/frontend/src/pages/PasswordResetRequest.tsx index 90dd412..b3a2975 100644 --- a/frontend/src/pages/PasswordResetRequest.tsx +++ b/frontend/src/pages/PasswordResetRequest.tsx @@ -1,112 +1,30 @@ -import React, { useState } from 'react'; import { Link } from 'react-router-dom'; -import axios from 'axios'; import { Logo } from '../components/Logo'; -const API_URL = import.meta.env.VITE_API_URL || "/api"; - export const PasswordResetRequest: React.FC = () => { - const [email, setEmail] = useState(''); - const [loading, setLoading] = useState(false); - const [success, setSuccess] = useState(false); - const [error, setError] = useState(''); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(''); - setLoading(true); - - try { - await axios.post(`${API_URL}/auth/password-reset-request`, { email }); - setSuccess(true); - } catch (err: unknown) { - let message = 'Failed to send reset email'; - if (axios.isAxiosError(err)) { - if (err.response?.status === 404) { - message = 'Password reset feature is not enabled on this server'; - } else if (err.response?.data?.message) { - message = err.response.data.message; - } else if (err.message) { - message = err.message; - } - } else if (err instanceof Error) { - message = err.message; - } - setError(message); - } finally { - setLoading(false); - } - }; - - if (success) { - return ( -
-
-
- -

- Check your email -

-

- If an account with that email exists, a password reset link has been sent. -

-
- - Back to login - -
-
-
-
- ); - } - return (

- Reset your password + Password help

- Enter your email address and we'll send you a link to reset your password. + This server does not send password reset emails.

-
- {error && ( -
-
{error}
+
+
+
+ Contact your administrator and ask them to generate a temporary password from the Admin dashboard.
- )} -
- - setEmail(e.target.value)} - /> -
- -
- +
+ If you are an admin and you’re locked out, run: +
+
+cd backend && node scripts/admin-recover.cjs --identifier you@example.com --generate --activate --disable-login-rate-limit
+            
@@ -117,7 +35,7 @@ export const PasswordResetRequest: React.FC = () => { Back to login
- +
); diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index d19f765..1a2f5ae 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -5,11 +5,13 @@ import { useAuth } from '../context/AuthContext'; import * as api from '../api'; import type { Collection } from '../types'; import { User, Lock, Save, X, Shield } from 'lucide-react'; +import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, USER_KEY } from '../utils/impersonation'; export const Profile: React.FC = () => { const { user: authUser, logout, authEnabled } = useAuth(); const navigate = useNavigate(); const isAdmin = authUser?.role === 'ADMIN'; + const mustResetPassword = Boolean(authUser?.mustResetPassword); const [collections, setCollections] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); @@ -20,6 +22,9 @@ export const Profile: React.FC = () => { // User info state const [name, setName] = useState(''); const [email, setEmail] = useState(''); + const [showEmailForm, setShowEmailForm] = useState(false); + const [emailCurrentPassword, setEmailCurrentPassword] = useState(''); + const [emailLoading, setEmailLoading] = useState(false); // Password change state const [currentPassword, setCurrentPassword] = useState(''); @@ -56,6 +61,12 @@ export const Profile: React.FC = () => { fetchData(); }, [authEnabled, authUser, isAdmin, navigate]); + useEffect(() => { + if (mustResetPassword) { + setShowPasswordForm(true); + } + }, [mustResetPassword]); + const handleToggleRegistration = async () => { if (!isAdmin || registrationEnabled === null) return; @@ -107,6 +118,10 @@ export const Profile: React.FC = () => { }; const handleUpdateName = async () => { + if (mustResetPassword) { + setError('You must reset your password before updating your profile'); + return; + } if (!name.trim()) { setError('Name cannot be empty'); return; @@ -191,6 +206,58 @@ export const Profile: React.FC = () => { } }; + const handleUpdateEmail = async () => { + if (mustResetPassword) { + setError('You must reset your password before changing your email'); + return; + } + if (!email.trim()) { + setError('Email cannot be empty'); + return; + } + if (!emailCurrentPassword) { + setError('Current password is required to change email'); + return; + } + + setEmailLoading(true); + setError(''); + setSuccess(''); + + try { + const response = await api.api.put<{ + user: { id: string; email: string; name: string; createdAt: string; updatedAt: string }; + accessToken: string; + refreshToken: string; + }>('/auth/email', { + email: email.trim(), + currentPassword: emailCurrentPassword, + }); + + localStorage.setItem(ACCESS_TOKEN_KEY, response.data.accessToken); + localStorage.setItem(REFRESH_TOKEN_KEY, response.data.refreshToken); + localStorage.setItem(USER_KEY, JSON.stringify(response.data.user)); + + setSuccess('Email updated successfully'); + setShowEmailForm(false); + setEmailCurrentPassword(''); + + setTimeout(() => window.location.reload(), 500); + } catch (err: unknown) { + let message = 'Failed to update email'; + if (api.isAxiosError(err)) { + if (err.response?.data?.message) { + message = err.response.data.message; + } else if (err.response?.data?.error) { + message = err.response.data.error; + } + } + setError(message); + } finally { + setEmailLoading(false); + } + }; + return ( { onEditCollection={handleEditCollection} onDeleteCollection={handleDeleteCollection} > -

+

Profile

@@ -226,20 +293,95 @@ export const Profile: React.FC = () => {

Personal Information

-
-
- - -

Email cannot be changed

-
+ {mustResetPassword && ( +
+

+ Password reset required +

+

+ Change your password below before using ExcaliDash. +

+
+ )} +
+
+ +
+ setEmail(e.target.value)} + disabled={!showEmailForm} + className={ + showEmailForm + ? "flex-1 px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 font-medium" + : "flex-1 px-4 py-3 bg-slate-50 dark:bg-neutral-800 border-2 border-slate-200 dark:border-neutral-700 rounded-xl text-slate-600 dark:text-neutral-400 cursor-not-allowed" + } + /> + {!showEmailForm && ( + + )} +
+ + {showEmailForm && ( +
+
+ + setEmailCurrentPassword(e.target.value)} + className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 font-medium" + placeholder="Enter current password" + /> +
+
+ + +
+
+ )} +
@@ -312,7 +454,7 @@ export const Profile: React.FC = () => {

Change Password

- {!showPasswordForm && ( + {!showPasswordForm && !mustResetPassword && ( - -
- - )} + {!mustResetPassword && ( + + )} + + + )} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 942b211..2e106ff 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -3,9 +3,9 @@ import { Layout } from '../components/Layout'; import { useNavigate } from 'react-router-dom'; import * as api from '../api'; import type { Collection } from '../types'; -import { Database, FileJson, Upload, Moon, Sun, Info, HardDrive } from 'lucide-react'; +import { Upload, Moon, Sun, Info, Archive } from 'lucide-react'; import { ConfirmModal } from '../components/ConfirmModal'; -import { importDrawings } from '../utils/importUtils'; +import { importLegacyFiles } from '../utils/importUtils'; import { useTheme } from '../context/ThemeContext'; import { useAuth } from '../context/AuthContext'; @@ -15,11 +15,41 @@ export const Settings: React.FC = () => { const { theme, toggleTheme } = useTheme(); const { authEnabled, user } = useAuth(); - const [importConfirmation, setImportConfirmation] = useState<{ isOpen: boolean; file: File | null }>({ isOpen: false, file: null }); + const [legacyDbImportConfirmation, setLegacyDbImportConfirmation] = useState<{ + isOpen: boolean; + file: File | null; + info: null | { + drawings: number; + collections: number; + legacyLatestMigration: string | null; + currentLatestMigration: string | null; + }; + }>({ isOpen: false, file: null, info: null }); const [importError, setImportError] = useState<{ isOpen: boolean; message: string }>({ isOpen: false, message: '' }); - const [importSuccess, setImportSuccess] = useState(false); + const [importSuccess, setImportSuccess] = useState<{ isOpen: boolean; message: React.ReactNode }>({ isOpen: false, message: '' }); + const [legacyDbImportLoading, setLegacyDbImportLoading] = useState(false); const [authToggleLoading, setAuthToggleLoading] = useState(false); const [authToggleError, setAuthToggleError] = useState(null); + const [authToggleConfirm, setAuthToggleConfirm] = useState<{ isOpen: boolean; nextEnabled: boolean | null }>({ + isOpen: false, + nextEnabled: null, + }); + + const [backupExportExt, setBackupExportExt] = useState<'excalidash' | 'excalidash.zip'>('excalidash'); + const [backupImportConfirmation, setBackupImportConfirmation] = useState<{ + isOpen: boolean; + file: File | null; + info: null | { + formatVersion: number; + exportedAt: string; + excalidashBackendVersion: string | null; + collections: number; + drawings: number; + }; + }>({ isOpen: false, file: null, info: null }); + const [backupImportLoading, setBackupImportLoading] = useState(false); + const [backupImportSuccess, setBackupImportSuccess] = useState(false); + const [backupImportError, setBackupImportError] = useState<{ isOpen: boolean; message: string }>({ isOpen: false, message: '' }); const appVersion = import.meta.env.VITE_APP_VERSION || 'Unknown version'; const buildLabel = import.meta.env.VITE_APP_BUILD_LABEL; @@ -36,15 +66,13 @@ export const Settings: React.FC = () => { fetchCollections(); }, []); - const toggleAuthEnabled = async () => { - if (authEnabled === null) return; + const setAuthEnabled = async (enabled: boolean) => { setAuthToggleLoading(true); setAuthToggleError(null); try { - const next = !authEnabled; const response = await api.api.post<{ authEnabled: boolean; bootstrapRequired?: boolean }>( '/auth/auth-enabled', - { enabled: next }, + { enabled }, ); if (response.data.authEnabled) { @@ -69,6 +97,110 @@ export const Settings: React.FC = () => { } }; + const confirmToggleAuthEnabled = () => { + if (authEnabled === null) return; + if (authToggleLoading) return; + setAuthToggleConfirm({ isOpen: true, nextEnabled: !authEnabled }); + }; + + const exportBackup = async () => { + try { + const extQuery = backupExportExt === 'excalidash.zip' ? '?ext=zip' : ''; + const response = await api.api.get(`/export/excalidash${extQuery}`, { responseType: 'blob' }); + const blob = new Blob([response.data], { type: 'application/zip' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + const date = new Date().toISOString().split('T')[0]; + link.download = backupExportExt === 'excalidash.zip' + ? `excalidash-backup-${date}.excalidash.zip` + : `excalidash-backup-${date}.excalidash`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + } catch (err: unknown) { + console.error('Backup export failed:', err); + setBackupImportError({ isOpen: true, message: 'Failed to export backup. Please try again.' }); + } + }; + + const verifyBackupFile = async (file: File) => { + setBackupImportLoading(true); + try { + const formData = new FormData(); + formData.append('archive', file); + const response = await api.api.post<{ + valid: boolean; + formatVersion: number; + exportedAt: string; + excalidashBackendVersion: string | null; + collections: number; + drawings: number; + }>('/import/excalidash/verify', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + + setBackupImportConfirmation({ + isOpen: true, + file, + info: { + formatVersion: response.data.formatVersion, + exportedAt: response.data.exportedAt, + excalidashBackendVersion: response.data.excalidashBackendVersion ?? null, + collections: response.data.collections, + drawings: response.data.drawings, + }, + }); + } catch (err: unknown) { + console.error('Backup verify failed:', err); + let message = 'Failed to verify backup file.'; + if (api.isAxiosError(err)) { + message = err.response?.data?.message || err.response?.data?.error || message; + } + setBackupImportError({ isOpen: true, message }); + } finally { + setBackupImportLoading(false); + } + }; + + const verifyLegacyDbFile = async (file: File) => { + setLegacyDbImportLoading(true); + try { + const formData = new FormData(); + formData.append('db', file); + const response = await api.api.post<{ + valid: boolean; + drawings: number; + collections: number; + latestMigration: string | null; + currentLatestMigration: string | null; + }>('/import/sqlite/legacy/verify', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + + setLegacyDbImportConfirmation({ + isOpen: true, + file, + info: { + drawings: response.data.drawings, + collections: response.data.collections, + legacyLatestMigration: response.data.latestMigration ?? null, + currentLatestMigration: response.data.currentLatestMigration ?? null, + }, + }); + } catch (err: unknown) { + console.error('Legacy DB verify failed:', err); + let message = 'Failed to verify legacy database file.'; + if (api.isAxiosError(err)) { + message = err.response?.data?.message || err.response?.data?.error || message; + } + setImportError({ isOpen: true, message }); + } finally { + setLegacyDbImportLoading(false); + } + }; + const handleCreateCollection = async (name: string) => { await api.createCollection(name); const newCollections = await api.getCollections(); @@ -102,7 +234,7 @@ export const Settings: React.FC = () => { onEditCollection={handleEditCollection} onDeleteCollection={handleDeleteCollection} > -

+

Settings

@@ -112,17 +244,78 @@ export const Settings: React.FC = () => { )} -
+
+
+
+ +
+
+

Export Backup

+

+ Exports an `.excalidash` archive (zip) organized by collections +

+
+
+ + +
+
+ +
+ { + const file = (e.target.files || [])[0]; + if (!file) return; + await verifyBackupFile(file); + e.target.value = ''; + }} + /> + +
+ - - - - - - -
- { - const files = Array.from(e.target.files || []); - if (files.length === 0) return; - - // Handle Database Import (.sqlite or .db) - const databaseFile = files.find(f => f.name.endsWith('.sqlite') || f.name.endsWith('.db')); - if (databaseFile) { - if (files.length > 1) { - setImportError({ isOpen: true, message: 'Please import database files separately from other files.' }); - e.target.value = ''; - return; - } - - const formData = new FormData(); - formData.append('db', databaseFile); - - try { - const res = await fetch(`${api.API_URL}/import/sqlite/verify`, { - method: 'POST', - body: formData, - }); - - if (!res.ok) { - const errorData = await res.json(); - setImportError({ isOpen: true, message: errorData.error || 'Invalid database file.' }); - e.target.value = ''; - return; - } - - setImportConfirmation({ isOpen: true, file: databaseFile }); - } catch (err) { - console.error('Verification failed:', err); - setImportError({ isOpen: true, message: 'Failed to verify database file.' }); - } - - e.target.value = ''; - return; - } - - const drawingFiles = files.filter(f => f.name.endsWith('.json') || f.name.endsWith('.excalidraw')); - if (drawingFiles.length === 0) { - setImportError({ isOpen: true, message: 'No supported files found.' }); - e.target.value = ''; - return; - } - - const result = await importDrawings(drawingFiles, null, () => { }); - - if (result.failed > 0) { - setImportError({ - isOpen: true, - message: `Import complete with errors.\nSuccess: ${result.success}\nFailed: ${result.failed}\nErrors:\n${result.errors.join('\n')}` - }); - } else { - setImportSuccess(true); - } - - e.target.value = ''; - }} - /> - -
- -
-
+
+
-

Version Info

+

Version Info

{appVersion} @@ -351,37 +371,115 @@ export const Settings: React.FC = () => {
+
+ + Advanced / Legacy + +
+
+ { + const files = Array.from(e.target.files || []); + if (files.length === 0) return; + + const databaseFile = files.find(f => f.name.endsWith('.sqlite') || f.name.endsWith('.db')); + if (databaseFile) { + if (files.length > 1) { + setImportError({ isOpen: true, message: 'Please import legacy database files separately from other files.' }); + e.target.value = ''; + return; + } + + await verifyLegacyDbFile(databaseFile); + e.target.value = ''; + return; + } + + const result = await importLegacyFiles(files, null, () => { }); + + if (result.failed > 0) { + setImportError({ + isOpen: true, + message: `Import complete with errors.\nSuccess: ${result.success}\nFailed: ${result.failed}\nErrors:\n${result.errors.join('\n')}` + }); + } else { + setImportSuccess({ isOpen: true, message: `Imported ${result.success} file(s).` }); + } + + e.target.value = ''; + }} + /> + +
+
+
+ +
This will merge legacy data into your account (it will not replace the server database).
+ {legacyDbImportConfirmation.info && ( +
+
Drawings: {legacyDbImportConfirmation.info.drawings}
+
Collections: {legacyDbImportConfirmation.info.collections}
+
Legacy migration: {legacyDbImportConfirmation.info.legacyLatestMigration || 'Unknown'}
+
Current migration: {legacyDbImportConfirmation.info.currentLatestMigration || 'Unknown'}
+
+ )} +
+ } + confirmText="Merge Import" + cancelText="Cancel" onConfirm={async () => { - if (!importConfirmation.file) return; + const file = legacyDbImportConfirmation.file; + if (!file) return; + setLegacyDbImportConfirmation({ isOpen: false, file: null, info: null }); const formData = new FormData(); - formData.append('db', importConfirmation.file); + formData.append('db', file); try { - const res = await fetch(`${api.API_URL}/import/sqlite`, { - method: 'POST', - body: formData, + const response = await api.api.post<{ + success: boolean; + collections: { created: number; updated: number; idConflicts: number }; + drawings: { created: number; updated: number; idConflicts: number }; + }>('/import/sqlite/legacy', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, }); - if (!res.ok) { - const errorData = await res.json(); - throw new Error(errorData.error || 'Import failed'); - } - - setImportConfirmation({ isOpen: false, file: null }); - setImportSuccess(true); - } catch (err: any) { + setImportSuccess({ + isOpen: true, + message: `Legacy DB imported. Collections: +${response.data.collections.created} / ~${response.data.collections.updated}. Drawings: +${response.data.drawings.created} / ~${response.data.drawings.updated}.`, + }); + } catch (err: unknown) { console.error(err); - setImportError({ isOpen: true, message: `Failed to import database: ${err.message}` }); - setImportConfirmation({ isOpen: false, file: null }); + let message = 'Failed to import legacy database.'; + if (api.isAxiosError(err)) { + message = err.response?.data?.message || err.response?.data?.error || message; + } + setImportError({ isOpen: true, message }); } }} - onCancel={() => setImportConfirmation({ isOpen: false, file: null })} + onCancel={() => setLegacyDbImportConfirmation({ isOpen: false, file: null, info: null })} /> { /> setImportSuccess(false)} - onCancel={() => setImportSuccess(false)} + onConfirm={() => setImportSuccess({ isOpen: false, message: '' })} + onCancel={() => setImportSuccess({ isOpen: false, message: '' })} + /> + + { + const nextEnabled = authToggleConfirm.nextEnabled; + setAuthToggleConfirm({ isOpen: false, nextEnabled: null }); + if (typeof nextEnabled !== 'boolean') return; + await setAuthEnabled(nextEnabled); + }} + onCancel={() => setAuthToggleConfirm({ isOpen: false, nextEnabled: null })} + /> + + { + const file = backupImportConfirmation.file; + if (!file) return; + setBackupImportConfirmation({ ...backupImportConfirmation, isOpen: false }); + setBackupImportLoading(true); + try { + const formData = new FormData(); + formData.append('archive', file); + await api.api.post('/import/excalidash', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + setBackupImportConfirmation({ isOpen: false, file: null, info: null }); + setBackupImportSuccess(true); + } catch (err: unknown) { + console.error('Backup import failed:', err); + let message = 'Failed to import backup.'; + if (api.isAxiosError(err)) { + message = err.response?.data?.message || err.response?.data?.error || message; + } + setBackupImportError({ isOpen: true, message }); + setBackupImportConfirmation({ isOpen: false, file: null, info: null }); + } finally { + setBackupImportLoading(false); + } + }} + onCancel={() => setBackupImportConfirmation({ isOpen: false, file: null, info: null })} + /> + + setBackupImportSuccess(false)} + onCancel={() => setBackupImportSuccess(false)} + /> + + setBackupImportError({ isOpen: false, message: '' })} + onCancel={() => setBackupImportError({ isOpen: false, message: '' })} /> ); diff --git a/frontend/src/utils/impersonation.ts b/frontend/src/utils/impersonation.ts new file mode 100644 index 0000000..56fa8a3 --- /dev/null +++ b/frontend/src/utils/impersonation.ts @@ -0,0 +1,47 @@ +export const ACCESS_TOKEN_KEY = 'excalidash-access-token'; +export const REFRESH_TOKEN_KEY = 'excalidash-refresh-token'; +export const USER_KEY = 'excalidash-user'; +export const IMPERSONATION_KEY = 'excalidash-impersonation'; + +export type ImpersonationState = { + original: { + accessToken: string; + refreshToken: string; + user: unknown; + }; + impersonator: { + id: string; + email: string; + name: string; + }; + target: { + id: string; + email: string; + name: string; + }; + startedAt: string; +}; + +export const readImpersonationState = (): ImpersonationState | null => { + if (typeof window === 'undefined') return null; + try { + const raw = localStorage.getItem(IMPERSONATION_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as ImpersonationState; + if (!parsed?.original?.accessToken || !parsed?.original?.refreshToken) return null; + return parsed; + } catch { + return null; + } +}; + +export const stopImpersonation = (): boolean => { + const state = readImpersonationState(); + if (!state) return false; + localStorage.setItem(ACCESS_TOKEN_KEY, state.original.accessToken); + localStorage.setItem(REFRESH_TOKEN_KEY, state.original.refreshToken); + localStorage.setItem(USER_KEY, JSON.stringify(state.original.user)); + localStorage.removeItem(IMPERSONATION_KEY); + return true; +}; + diff --git a/frontend/src/utils/importUtils.ts b/frontend/src/utils/importUtils.ts index 341174f..f1748ba 100644 --- a/frontend/src/utils/importUtils.ts +++ b/frontend/src/utils/importUtils.ts @@ -2,6 +2,43 @@ import { exportToSvg } from "@excalidraw/excalidraw"; import { api } from "../api"; import { type UploadStatus } from "../context/UploadContext"; +type LegacyExportDrawing = { + id?: string; + name?: string; + elements: unknown[]; + appState: Record; + files?: Record; + collectionId?: string | null; + collectionName?: string | null; + createdAt?: string | number; + updatedAt?: string | number; + preview?: string | null; + version?: number; +}; + +type LegacyExportJson = { + version?: string; + exportedAt?: string; + userId?: string; + drawings: LegacyExportDrawing[]; +}; + +const isLegacyExportJson = (data: unknown): data is LegacyExportJson => { + if (typeof data !== "object" || data === null) return false; + const maybe = data as Record; + if (!Array.isArray(maybe.drawings)) return false; + return true; +}; + +const coerceTimestamp = (value: unknown): number => { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = Date.parse(value); + if (!Number.isNaN(parsed)) return parsed; + } + return Date.now(); +}; + export const importDrawings = async ( files: File[], targetCollectionId: string | null, @@ -109,3 +146,186 @@ export const importDrawings = async ( return { success: successCount, failed: failCount, errors }; }; + +/** + * Legacy import helper. + * - Supports individual `.excalidraw` / Excalidraw `.json` drawings (same as importDrawings) + * - Supports legacy ExcaliDash export `.json` with `{ drawings: [...] }` + */ +export const importLegacyFiles = async ( + files: File[], + targetCollectionId: string | null, + onSuccess?: () => void | Promise, + onProgress?: ( + fileIndex: number, + status: UploadStatus, + progress: number, + error?: string + ) => void +) => { + const drawingFiles = files.filter( + (f) => f.name.endsWith(".json") || f.name.endsWith(".excalidraw") + ); + + if (drawingFiles.length === 0) { + return { success: 0, failed: 0, errors: ["No supported files found."] }; + } + + // If there's a legacy export JSON among the selected files, import it separately. + // (We still allow mixing with individual .excalidraw files.) + let successCount = 0; + let failCount = 0; + const errors: string[] = []; + + const originalIndexMap = new Map(); + drawingFiles.forEach((df, i) => { + const originalIndex = files.indexOf(df); + originalIndexMap.set(i, originalIndex); + }); + + // Pre-load existing collections once (for legacy export import mapping by name) + let existingCollectionsByLowerName: Map | null = null; + const ensureCollectionsIndex = async () => { + if (existingCollectionsByLowerName) return; + const response = await api.get<{ id: string; name: string }[]>( + "/collections" + ); + existingCollectionsByLowerName = new Map( + (response.data || []) + .filter((c) => c && typeof c.name === "string" && typeof c.id === "string") + .map((c) => [c.name.trim().toLowerCase(), c.id]) + ); + }; + + const getOrCreateCollectionIdByName = async (name: string) => { + await ensureCollectionsIndex(); + const key = name.trim().toLowerCase(); + const existing = existingCollectionsByLowerName!.get(key); + if (existing) return existing; + const created = await api.post<{ id: string; name: string }>("/collections", { + name, + }); + existingCollectionsByLowerName!.set(key, created.data.id); + return created.data.id; + }; + + await Promise.all( + drawingFiles.map(async (file, drawingIndex) => { + const fileIndex = originalIndexMap.get(drawingIndex) ?? drawingIndex; + try { + if (onProgress) onProgress(fileIndex, "processing", 0); + + const text = await file.text(); + const parsed = JSON.parse(text) as unknown; + + if (isLegacyExportJson(parsed)) { + const exportJson = parsed; + const drawings = Array.isArray(exportJson.drawings) + ? exportJson.drawings + : []; + + if (drawings.length === 0) { + throw new Error("Legacy export JSON contains no drawings."); + } + + // Import each drawing entry + for (let i = 0; i < drawings.length; i += 1) { + const d = drawings[i] as LegacyExportDrawing; + const elements = Array.isArray(d.elements) ? d.elements : null; + const appState = + typeof d.appState === "object" && d.appState !== null + ? (d.appState as Record) + : null; + if (!elements || !appState) { + failCount += 1; + errors.push( + `${file.name}: drawing ${i + 1}: Invalid structure (missing elements/appState)` + ); + continue; + } + + let collectionId: string | null = null; + if (targetCollectionId !== null) { + collectionId = targetCollectionId; + } else if (d.collectionId === "trash" || d.collectionName === "Trash") { + collectionId = "trash"; + } else if (typeof d.collectionName === "string" && d.collectionName.trim()) { + collectionId = await getOrCreateCollectionIdByName(d.collectionName.trim()); + } else { + collectionId = null; + } + + const svg = await exportToSvg({ + elements, + appState: { + ...appState, + exportBackground: true, + viewBackgroundColor: + (appState as any).viewBackgroundColor || "#ffffff", + }, + files: (d.files && typeof d.files === "object" ? d.files : {}) as any, + exportPadding: 10, + }); + + const payload = { + name: + typeof d.name === "string" && d.name.trim().length > 0 + ? d.name + : `Imported Drawing ${i + 1}`, + elements, + appState, + files: d.files || null, + collectionId, + createdAt: coerceTimestamp(d.createdAt), + updatedAt: coerceTimestamp(d.updatedAt), + preview: svg.outerHTML, + }; + + await api.post("/drawings", payload, { + headers: { + "X-Imported-File": "true", + }, + }); + + successCount += 1; + } + + if (onProgress) onProgress(fileIndex, "success", 100); + return; + } + + // Single Excalidraw drawing json + if ( + typeof parsed === "object" && + parsed !== null && + (parsed as any).elements && + (parsed as any).appState + ) { + const result = await importDrawings([file], targetCollectionId, undefined, onProgress); + successCount += result.success; + failCount += result.failed; + errors.push(...result.errors); + return; + } + + throw new Error(`Invalid file structure: ${file.name}`); + } catch (err: any) { + console.error(`Failed to import ${file.name}:`, err); + failCount += 1; + const errorMessage = + err?.response?.data?.message || + err?.response?.data?.error || + err?.message || + "Upload failed"; + errors.push(`${file.name}: ${errorMessage}`); + if (onProgress) onProgress(fileIndex, "error", 0, errorMessage); + } + }) + ); + + if (successCount > 0 && onSuccess) { + await onSuccess(); + } + + return { success: successCount, failed: failCount, errors }; +};