Add admin password reset flow
This commit is contained in:
Generated
+70
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+5
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 "<new 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;
|
||||
});
|
||||
+1020
-28
File diff suppressed because it is too large
Load Diff
+730
-205
File diff suppressed because it is too large
Load Diff
@@ -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<string, unknown>;
|
||||
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) {
|
||||
|
||||
@@ -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() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Admin />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/editor/:id"
|
||||
element={
|
||||
|
||||
@@ -105,6 +105,24 @@ api.interceptors.request.use(
|
||||
api.interceptors.response.use(
|
||||
(response) => 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);
|
||||
|
||||
@@ -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<ConfirmModalProps> = ({
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl font-bold text-neutral-900 dark:text-neutral-100 tracking-tight">{title}</h3>
|
||||
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400 leading-relaxed">
|
||||
<div className="text-sm font-medium text-neutral-500 dark:text-neutral-400 leading-relaxed">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 w-full mt-2">
|
||||
|
||||
@@ -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 (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={viewBox}
|
||||
role="img"
|
||||
aria-label={title}
|
||||
className={className}
|
||||
>
|
||||
<title>{title}</title>
|
||||
<rect
|
||||
x={-padding}
|
||||
y={-padding}
|
||||
width={5 + padding * 2}
|
||||
height={5 + padding * 2}
|
||||
rx={1.4}
|
||||
fill={background}
|
||||
className="dark:hidden"
|
||||
/>
|
||||
<rect
|
||||
x={-padding}
|
||||
y={-padding}
|
||||
width={5 + padding * 2}
|
||||
height={5 + padding * 2}
|
||||
rx={1.4}
|
||||
fill={backgroundDark}
|
||||
className="hidden dark:block"
|
||||
/>
|
||||
{cells.map((row, r) =>
|
||||
row.map((on, c) =>
|
||||
on ? <rect key={`${r}-${c}`} x={c} y={r} width={1} height={1} rx={0.2} fill={foreground} /> : null
|
||||
)
|
||||
)}
|
||||
<rect
|
||||
x={-padding}
|
||||
y={-padding}
|
||||
width={5 + padding * 2}
|
||||
height={5 + padding * 2}
|
||||
rx={1.4}
|
||||
fill="none"
|
||||
stroke="rgba(0,0,0,0.25)"
|
||||
className="dark:stroke-neutral-700"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -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<LayoutProps> = ({
|
||||
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<HTMLDivElement>(null);
|
||||
const startXRef = useRef(0);
|
||||
const startWidthRef = useRef(0);
|
||||
@@ -61,39 +67,115 @@ export const Layout: React.FC<LayoutProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
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 (
|
||||
<div className="h-screen w-full bg-[#F3F4F6] dark:bg-neutral-950 p-4 transition-colors duration-200 overflow-hidden">
|
||||
<div className="flex gap-4 items-start h-full">
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
className="flex-shrink-0 h-full bg-white dark:bg-neutral-900 rounded-2xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] overflow-hidden z-20 transition-colors duration-200 relative"
|
||||
style={{ width: `${sidebarWidth}px` }}
|
||||
>
|
||||
<Sidebar
|
||||
collections={collections}
|
||||
selectedCollectionId={selectedCollectionId}
|
||||
onSelectCollection={onSelectCollection}
|
||||
onCreateCollection={onCreateCollection}
|
||||
onEditCollection={onEditCollection}
|
||||
onDeleteCollection={onDeleteCollection}
|
||||
onDrop={onDrop}
|
||||
/>
|
||||
|
||||
{/* Resize Handle */}
|
||||
<div className="h-screen w-full bg-[#F3F4F6] dark:bg-neutral-950 p-2 sm:p-4 transition-colors duration-200 overflow-hidden">
|
||||
{isMobile ? (
|
||||
<div className="relative h-full min-w-0">
|
||||
<main className="h-full min-w-0 bg-white/40 dark:bg-neutral-900/40 backdrop-blur-sm rounded-2xl border border-white/50 dark:border-neutral-800/50 shadow-sm transition-colors duration-200 overflow-hidden flex flex-col">
|
||||
<div className="px-3 pt-3 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSidebarOpen(v => !v)}
|
||||
className="inline-flex items-center justify-center h-11 w-11 rounded-xl border-2 border-black dark:border-neutral-700 bg-white/90 dark:bg-neutral-900/90 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] backdrop-blur-sm text-slate-900 dark:text-neutral-200 hover:-translate-y-0.5 transition-all"
|
||||
title={isSidebarOpen ? 'Close menu' : 'Open menu'}
|
||||
aria-label={isSidebarOpen ? 'Close menu' : 'Open menu'}
|
||||
>
|
||||
{isSidebarOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 overflow-y-auto">
|
||||
<div className="max-w-[1600px] w-full mx-auto p-4 sm:p-6 lg:p-8 min-h-full">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div
|
||||
className={`absolute top-0 right-0 w-1.5 h-full cursor-col-resize bg-transparent hover:bg-indigo-400 dark:hover:bg-indigo-500 transition-all duration-150 ${isResizing ? 'bg-indigo-500 dark:bg-indigo-400 w-2' : ''} group`}
|
||||
onMouseDown={handleMouseDown}
|
||||
title="Drag to resize sidebar"
|
||||
className={clsx(
|
||||
'fixed inset-0 z-30 bg-neutral-900/20 backdrop-blur-sm transition-opacity duration-150',
|
||||
isSidebarOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
)}
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
/>
|
||||
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
className={clsx(
|
||||
'fixed inset-y-4 left-2 sm:left-4 z-40 bg-white dark:bg-neutral-900 rounded-2xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] overflow-hidden transition-transform duration-200',
|
||||
isSidebarOpen ? 'translate-x-0' : '-translate-x-[110%]'
|
||||
)}
|
||||
style={{ width: `${sidebarWidth}px` }}
|
||||
>
|
||||
<div className="absolute inset-y-0 -left-0.5 -right-0.5 bg-transparent hover:bg-indigo-500/10 dark:hover:bg-indigo-400/10 transition-colors duration-150" />
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 bg-white/40 dark:bg-neutral-900/40 backdrop-blur-sm rounded-2xl border border-white/50 dark:border-neutral-800/50 shadow-sm h-full transition-colors duration-200 overflow-y-auto">
|
||||
<div className="max-w-[1600px] mx-auto p-6 lg:p-8 min-h-full">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<Sidebar
|
||||
collections={collections}
|
||||
selectedCollectionId={selectedCollectionId}
|
||||
onSelectCollection={onSelectCollection}
|
||||
onCreateCollection={onCreateCollection}
|
||||
onEditCollection={onEditCollection}
|
||||
onDeleteCollection={onDeleteCollection}
|
||||
onDrop={onDrop}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`absolute top-0 right-0 w-1.5 h-full cursor-col-resize bg-transparent hover:bg-indigo-400 dark:hover:bg-indigo-500 transition-all duration-150 ${isResizing ? 'bg-indigo-500 dark:bg-indigo-400 w-2' : ''} group`}
|
||||
onMouseDown={handleMouseDown}
|
||||
title="Drag to resize sidebar"
|
||||
>
|
||||
<div className="absolute inset-y-0 -left-0.5 -right-0.5 bg-transparent hover:bg-indigo-500/10 dark:hover:bg-indigo-400/10 transition-colors duration-150" />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-3 sm:gap-4 items-start h-full min-w-0">
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
className="flex-shrink-0 h-full bg-white dark:bg-neutral-900 rounded-2xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] overflow-hidden z-20 transition-colors duration-200 relative"
|
||||
style={{ width: `${sidebarWidth}px` }}
|
||||
>
|
||||
<Sidebar
|
||||
collections={collections}
|
||||
selectedCollectionId={selectedCollectionId}
|
||||
onSelectCollection={onSelectCollection}
|
||||
onCreateCollection={onCreateCollection}
|
||||
onEditCollection={onEditCollection}
|
||||
onDeleteCollection={onDeleteCollection}
|
||||
onDrop={onDrop}
|
||||
/>
|
||||
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
className={`absolute top-0 right-0 w-1.5 h-full cursor-col-resize bg-transparent hover:bg-indigo-400 dark:hover:bg-indigo-500 transition-all duration-150 ${isResizing ? 'bg-indigo-500 dark:bg-indigo-400 w-2' : ''} group`}
|
||||
onMouseDown={handleMouseDown}
|
||||
title="Drag to resize sidebar"
|
||||
>
|
||||
<div className="absolute inset-y-0 -left-0.5 -right-0.5 bg-transparent hover:bg-indigo-500/10 dark:hover:bg-indigo-400/10 transition-colors duration-150" />
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 min-w-0 bg-white/40 dark:bg-neutral-900/40 backdrop-blur-sm rounded-2xl border border-white/50 dark:border-neutral-800/50 shadow-sm h-full transition-colors duration-200 overflow-y-auto">
|
||||
<div className="max-w-[1600px] w-full mx-auto p-4 sm:p-6 lg:p-8 min-h-full">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
<UploadStatus />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<ProtectedRouteProps> = ({ 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<ProtectedRouteProps> = ({ children }) => {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// Force password reset before allowing app access.
|
||||
if (user?.mustResetPassword && location.pathname !== '/login') {
|
||||
return <Navigate to="/login?mustReset=1" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
@@ -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<SidebarProps> = ({
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { logout, user, authEnabled } = useAuth();
|
||||
const isAdmin = user?.role === 'ADMIN';
|
||||
const [impersonation, setImpersonation] = useState<ImpersonationState | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newCollectionName, setNewCollectionName] = useState('');
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
@@ -137,6 +141,17 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
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<SidebarProps> = ({
|
||||
return (
|
||||
<>
|
||||
<div className="w-full flex flex-col h-full bg-transparent">
|
||||
<div className="p-5 pb-2">
|
||||
<div className="p-4 sm:p-5 pb-2">
|
||||
<h1 className="text-2xl text-slate-900 dark:text-white flex items-center gap-3 tracking-tight" style={{ fontFamily: 'Excalifont' }}>
|
||||
<Logo className="w-10 h-10" />
|
||||
<span className="mt-1">ExcaliDash</span>
|
||||
@@ -178,7 +193,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
</div>
|
||||
|
||||
<nav
|
||||
className="flex-1 overflow-y-auto py-4 space-y-8 custom-scrollbar"
|
||||
className="flex-1 overflow-y-auto py-3 sm:py-4 space-y-4 sm:space-y-8 custom-scrollbar"
|
||||
onContextMenu={handleBackgroundContextMenu}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
@@ -260,7 +275,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="px-3 pt-4 pb-4 border-t border-slate-200/50 dark:border-slate-700/50 space-y-2">
|
||||
<div className="px-3 pt-3 sm:pt-4 pb-3 sm:pb-4 border-t border-slate-200/50 dark:border-slate-700/50 space-y-2">
|
||||
<button
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -301,6 +316,21 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{authEnabled && isAdmin && (
|
||||
<button
|
||||
onClick={() => navigate('/admin')}
|
||||
className={clsx(
|
||||
"w-full flex items-center gap-3 px-3 py-2 text-sm font-bold rounded-xl transition-all duration-200 border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]",
|
||||
selectedCollectionId === 'ADMIN'
|
||||
? "bg-indigo-50 dark:bg-neutral-800 text-indigo-900 dark:text-neutral-200 -translate-y-0.5"
|
||||
: "bg-white dark:bg-neutral-900 text-slate-900 dark:text-neutral-200 hover:bg-slate-50 dark:hover:bg-neutral-800 hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5"
|
||||
)}
|
||||
>
|
||||
<Shield size={18} />
|
||||
<span className="min-w-0 flex-1 text-left">Admin</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/settings')}
|
||||
className={clsx(
|
||||
@@ -317,10 +347,42 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
||||
{/* User info and logout */}
|
||||
{authEnabled && (
|
||||
<div className="mt-auto pt-4 border-t-2 border-slate-200 dark:border-neutral-700">
|
||||
{impersonation && (
|
||||
<div className="px-3 pb-2">
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-200 dark:border-amber-800 rounded-xl flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-bold text-amber-900 dark:text-amber-200 uppercase tracking-wide">
|
||||
Impersonating
|
||||
</div>
|
||||
<div className="text-xs font-semibold text-amber-900 dark:text-amber-200 truncate">
|
||||
{user?.email}
|
||||
</div>
|
||||
<div className="text-[11px] text-amber-800/80 dark:text-amber-200/70 truncate">
|
||||
Return to {impersonation.impersonator.email}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!restoreImpersonation()) return;
|
||||
window.location.reload();
|
||||
}}
|
||||
className="px-2.5 py-1.5 text-[11px] font-bold rounded-lg border-2 border-amber-300 dark:border-amber-700 bg-white dark:bg-neutral-900 text-amber-800 dark:text-amber-200 hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-all flex-shrink-0"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{user && (
|
||||
<div className="px-3 py-2 text-xs text-slate-500 dark:text-neutral-500 mb-2">
|
||||
<div className="font-semibold text-slate-700 dark:text-neutral-300">{user.name}</div>
|
||||
<div className="truncate">{user.email}</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<FingerprintAvatar size={28} className="flex-shrink-0 sm:hidden" title="Browser profile" />
|
||||
<FingerprintAvatar size={32} className="flex-shrink-0 hidden sm:block" title="Browser profile" />
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-slate-700 dark:text-neutral-300 truncate leading-tight">{user.name}</div>
|
||||
<div className="truncate leading-tight">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -674,12 +674,12 @@ export const Dashboard: React.FC = () => {
|
||||
</DragOverlayPortal>
|
||||
)}
|
||||
|
||||
<h1 className="text-5xl mb-8 text-slate-900 dark:text-white pl-1" style={{ fontFamily: 'Excalifont' }}>
|
||||
<h1 className="text-3xl sm:text-5xl mb-6 sm:mb-8 text-slate-900 dark:text-white pl-1" style={{ fontFamily: 'Excalifont' }}>
|
||||
{viewTitle}
|
||||
</h1>
|
||||
|
||||
<div className="mb-8 flex flex-col xl:flex-row items-center justify-between gap-4">
|
||||
<div className="flex flex-1 w-full gap-3 items-center">
|
||||
<div className="flex flex-1 w-full gap-3 items-center flex-wrap">
|
||||
<div className="relative flex-1 group max-w-md transition-all duration-200 focus-within:-translate-y-0.5">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
@@ -696,7 +696,7 @@ export const Dashboard: React.FC = () => {
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1">
|
||||
<div className="flex items-center gap-2 p-1 flex-wrap">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -704,7 +704,7 @@ export const Dashboard: React.FC = () => {
|
||||
setShowSortMenu(!showSortMenu);
|
||||
}}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-bold transition-all border-2 border-black dark:border-neutral-700 whitespace-nowrap h-[42px] w-[180px]",
|
||||
"flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-bold transition-all border-2 border-black dark:border-neutral-700 whitespace-nowrap h-[42px] w-full sm:w-[180px]",
|
||||
"bg-white dark:bg-neutral-900 text-slate-700 dark:text-neutral-300 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 hover:bg-indigo-50 dark:hover:bg-indigo-900/30"
|
||||
)}
|
||||
>
|
||||
@@ -914,11 +914,12 @@ export const Dashboard: React.FC = () => {
|
||||
>
|
||||
{isDraggingFile && (
|
||||
<div className="absolute inset-0 z-50 bg-white/80 backdrop-blur-sm border-4 border-dashed border-indigo-400 rounded-3xl flex flex-col items-center justify-center animate-in fade-in duration-200">
|
||||
<div className="bg-indigo-50 p-8 rounded-full mb-6 shadow-sm">
|
||||
<Inbox size={64} className="text-indigo-600" />
|
||||
<div className="bg-indigo-50 p-6 sm:p-8 rounded-full mb-5 sm:mb-6 shadow-sm">
|
||||
<Inbox size={56} className="text-indigo-600 hidden sm:block" />
|
||||
<Inbox size={44} className="text-indigo-600 sm:hidden" />
|
||||
</div>
|
||||
<h3 className="text-3xl font-bold text-slate-800 mb-2">Drop files to import</h3>
|
||||
<p className="text-slate-500 text-lg max-w-md text-center">
|
||||
<h3 className="text-2xl sm:text-3xl font-bold text-slate-800 mb-2 text-center px-4">Drop files to import</h3>
|
||||
<p className="text-slate-500 text-base sm:text-lg max-w-sm sm:max-w-md text-center px-4">
|
||||
Drop .excalidraw or .json files here to add them to
|
||||
<span className="font-bold text-indigo-600 mx-1">
|
||||
{viewTitle}
|
||||
@@ -932,9 +933,9 @@ export const Dashboard: React.FC = () => {
|
||||
<Loader2 size={32} className="animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className={clsx("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 pb-24 transition-all duration-300", isDraggingFile && "opacity-20 blur-sm")}>
|
||||
<div className={clsx("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6 pb-16 sm:pb-24 transition-all duration-300", isDraggingFile && "opacity-20 blur-sm")}>
|
||||
{sortedDrawings.length === 0 ? (
|
||||
<div className="col-span-full flex flex-col items-center justify-center py-32 text-slate-400 dark:text-neutral-500 border-2 border-dashed border-slate-200 dark:border-neutral-700 rounded-3xl bg-slate-50/50 dark:bg-neutral-800/50">
|
||||
<div className="col-span-full flex flex-col items-center justify-center py-16 sm:py-32 text-slate-400 dark:text-neutral-500 border-2 border-dashed border-slate-200 dark:border-neutral-700 rounded-3xl bg-slate-50/50 dark:bg-neutral-800/50">
|
||||
<div className="w-20 h-20 bg-white dark:bg-slate-800 rounded-full shadow-sm border border-slate-100 dark:border-slate-700 flex items-center justify-center mb-6">
|
||||
{isTrashView ? <Trash2 size={32} className="text-slate-300 dark:text-slate-600" /> : <Inbox size={32} className="text-slate-300 dark:text-slate-600" />}
|
||||
</div>
|
||||
|
||||
+175
-55
@@ -1,15 +1,22 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useNavigate, Link, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { Logo } from '../components/Logo';
|
||||
import * as api from '../api';
|
||||
import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, USER_KEY } from '../utils/impersonation';
|
||||
|
||||
export const Login: React.FC = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmNewPassword, setConfirmNewPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login, authEnabled, bootstrapRequired, isAuthenticated, loading: authLoading } = useAuth();
|
||||
const { login, logout, authEnabled, bootstrapRequired, isAuthenticated, loading: authLoading, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryMustReset = searchParams.get('mustReset') === '1';
|
||||
const mustReset = Boolean(user?.mustResetPassword) || queryMustReset;
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading || authEnabled === null) return;
|
||||
@@ -22,9 +29,10 @@ export const Login: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
if (isAuthenticated) {
|
||||
if (mustReset) return;
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}, [authEnabled, authLoading, bootstrapRequired, isAuthenticated, navigate]);
|
||||
}, [authEnabled, authLoading, bootstrapRequired, isAuthenticated, mustReset, navigate]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -33,6 +41,12 @@ export const Login: React.FC = () => {
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
const stored = localStorage.getItem(USER_KEY);
|
||||
const storedUser = stored ? (JSON.parse(stored) as { mustResetPassword?: boolean } | null) : null;
|
||||
if (storedUser?.mustResetPassword) {
|
||||
setPassword('');
|
||||
return;
|
||||
}
|
||||
navigate('/');
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to login';
|
||||
@@ -42,73 +56,163 @@ export const Login: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMustReset = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!newPassword || !confirmNewPassword) {
|
||||
setError('Please enter and confirm a new password');
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
setError('New password must be at least 8 characters long');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmNewPassword) {
|
||||
setError('New passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.api.post<{
|
||||
user: { id: string; email: string; name: string; role?: string; mustResetPassword?: boolean };
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}>('/auth/must-reset-password', { newPassword });
|
||||
|
||||
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));
|
||||
|
||||
window.location.href = '/';
|
||||
} catch (err: unknown) {
|
||||
let message = 'Failed to reset password';
|
||||
if (api.isAxiosError(err)) {
|
||||
message = err.response?.data?.message || err.response?.data?.error || message;
|
||||
}
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="text-center">
|
||||
<Logo className="mx-auto h-12 w-auto" />
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
Sign in to your account
|
||||
{mustReset ? 'Reset your password' : 'Sign in to your account'}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Or{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
create a new account
|
||||
</Link>
|
||||
</p>
|
||||
{!mustReset ? (
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Or{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
create a new account
|
||||
</Link>
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Your admin requires you to set a new password before using ExcaliDash.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<form className="mt-8 space-y-6" onSubmit={mustReset ? handleMustReset : handleSubmit}>
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
|
||||
<div className="text-sm text-red-800 dark:text-red-200">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{!mustReset ? (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="newPassword" className="sr-only">
|
||||
New password
|
||||
</label>
|
||||
<input
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="New password (min 8 characters)"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="confirmNewPassword" className="sr-only">
|
||||
Confirm new password
|
||||
</label>
|
||||
<input
|
||||
id="confirmNewPassword"
|
||||
name="confirmNewPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Confirm new password"
|
||||
value={confirmNewPassword}
|
||||
onChange={(e) => setConfirmNewPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Link
|
||||
to="/reset-password"
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
{!mustReset && (
|
||||
<div className="flex justify-end">
|
||||
<Link
|
||||
to="/reset-password"
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
@@ -116,9 +220,25 @@ export const Login: React.FC = () => {
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
{mustReset ? (loading ? 'Updating...' : 'Set new password') : (loading ? 'Signing in...' : 'Sign in')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mustReset && (
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNewPassword('');
|
||||
setConfirmNewPassword('');
|
||||
logout();
|
||||
}}
|
||||
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
Sign in as a different user
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="text-center">
|
||||
<Logo className="mx-auto h-12 w-auto" />
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
Check your email
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
If an account with that email exists, a password reset link has been sent.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="text-center">
|
||||
<Logo className="mx-auto h-12 w-auto" />
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
Reset your password
|
||||
Password help
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Enter your email address and we'll send you a link to reset your password.
|
||||
This server does not send password reset emails.
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4">
|
||||
<div className="text-sm text-red-800 dark:text-red-200">{error}</div>
|
||||
<div className="mt-8 space-y-6">
|
||||
<div className="rounded-md bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 p-4 text-left space-y-3">
|
||||
<div className="text-sm text-gray-700 dark:text-gray-200">
|
||||
Contact your administrator and ask them to generate a temporary password from the Admin dashboard.
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Sending...' : 'Send reset link'}
|
||||
</button>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-300">
|
||||
If you are an admin and you’re locked out, run:
|
||||
</div>
|
||||
<pre className="text-xs bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md p-3 overflow-x-auto">
|
||||
cd backend && node scripts/admin-recover.cjs --identifier you@example.com --generate --activate --disable-login-rate-limit
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
@@ -117,7 +35,7 @@ export const PasswordResetRequest: React.FC = () => {
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+185
-41
@@ -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<Collection[]>([]);
|
||||
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 (
|
||||
<Layout
|
||||
collections={collections}
|
||||
@@ -200,7 +267,7 @@ export const Profile: React.FC = () => {
|
||||
onEditCollection={handleEditCollection}
|
||||
onDeleteCollection={handleDeleteCollection}
|
||||
>
|
||||
<h1 className="text-5xl mb-8 text-slate-900 dark:text-white pl-1" style={{ fontFamily: 'Excalifont' }}>
|
||||
<h1 className="text-3xl sm:text-5xl mb-6 sm:mb-8 text-slate-900 dark:text-white pl-1" style={{ fontFamily: 'Excalifont' }}>
|
||||
Profile
|
||||
</h1>
|
||||
|
||||
@@ -226,20 +293,95 @@ export const Profile: React.FC = () => {
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Personal Information</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-bold text-slate-700 dark:text-neutral-300 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
disabled
|
||||
className="w-full 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"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-neutral-500">Email cannot be changed</p>
|
||||
</div>
|
||||
{mustResetPassword && (
|
||||
<div className="p-4 bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-200 dark:border-amber-800 rounded-xl">
|
||||
<p className="text-amber-900 dark:text-amber-200 font-bold">
|
||||
Password reset required
|
||||
</p>
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200/80 font-medium mt-1">
|
||||
Change your password below before using ExcaliDash.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-bold text-slate-700 dark:text-neutral-300 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => 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 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowEmailForm(true);
|
||||
setEmailCurrentPassword('');
|
||||
setError('');
|
||||
setSuccess('');
|
||||
}}
|
||||
disabled={mustResetPassword}
|
||||
className="px-6 py-3 bg-white dark:bg-neutral-800 text-slate-700 dark:text-neutral-300 font-bold rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 transition-all duration-200"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showEmailForm && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<label htmlFor="emailCurrentPassword" className="block text-sm font-bold text-slate-700 dark:text-neutral-300 mb-2">
|
||||
Current Password
|
||||
</label>
|
||||
<input
|
||||
id="emailCurrentPassword"
|
||||
type="password"
|
||||
value={emailCurrentPassword}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleUpdateEmail}
|
||||
disabled={
|
||||
emailLoading ||
|
||||
!email.trim() ||
|
||||
!emailCurrentPassword ||
|
||||
email.trim() === authUser?.email
|
||||
}
|
||||
className="flex-1 px-6 py-3 bg-indigo-600 dark:bg-indigo-500 text-white font-bold rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{emailLoading ? 'Saving...' : 'Save Email'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowEmailForm(false);
|
||||
setEmail(authUser?.email || '');
|
||||
setEmailCurrentPassword('');
|
||||
setError('');
|
||||
}}
|
||||
disabled={emailLoading}
|
||||
className="px-6 py-3 bg-white dark:bg-neutral-800 text-slate-700 dark:text-neutral-300 font-bold rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<X size={18} />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-bold text-slate-700 dark:text-neutral-300 mb-2">
|
||||
@@ -254,14 +396,14 @@ export const Profile: React.FC = () => {
|
||||
className="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"
|
||||
placeholder="Your name"
|
||||
/>
|
||||
<button
|
||||
onClick={handleUpdateName}
|
||||
disabled={loading || !name.trim() || name === authUser?.name}
|
||||
className="px-6 py-3 bg-indigo-600 dark:bg-indigo-500 text-white font-bold rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0 disabled:hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:disabled:hover:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] flex items-center gap-2"
|
||||
>
|
||||
<Save size={18} />
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpdateName}
|
||||
disabled={mustResetPassword || loading || !name.trim() || name === authUser?.name}
|
||||
className="px-6 py-3 bg-indigo-600 dark:bg-indigo-500 text-white font-bold rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0 disabled:hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:disabled:hover:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] flex items-center gap-2"
|
||||
>
|
||||
<Save size={18} />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,7 +454,7 @@ export const Profile: React.FC = () => {
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Change Password</h2>
|
||||
</div>
|
||||
{!showPasswordForm && (
|
||||
{!showPasswordForm && !mustResetPassword && (
|
||||
<button
|
||||
onClick={() => setShowPasswordForm(true)}
|
||||
className="px-4 py-2 bg-rose-600 dark:bg-rose-500 text-white font-bold rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 transition-all duration-200"
|
||||
@@ -374,23 +516,25 @@ export const Profile: React.FC = () => {
|
||||
>
|
||||
{loading ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowPasswordForm(false);
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setError('');
|
||||
}}
|
||||
disabled={loading}
|
||||
className="px-6 py-3 bg-white dark:bg-neutral-800 text-slate-700 dark:text-neutral-300 font-bold rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<X size={18} />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!mustResetPassword && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowPasswordForm(false);
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setError('');
|
||||
}}
|
||||
disabled={loading}
|
||||
className="px-6 py-3 bg-white dark:bg-neutral-800 text-slate-700 dark:text-neutral-300 font-bold rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<X size={18} />
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
+398
-217
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
files?: Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<void>,
|
||||
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<number, number>();
|
||||
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<string, string> | 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<string, unknown>)
|
||||
: 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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user