Add admin password reset flow

This commit is contained in:
Zimeng Xiong
2026-02-06 14:11:13 -08:00
parent e4941ad77f
commit 1e617025df
23 changed files with 4205 additions and 698 deletions
+70
View File
@@ -29,6 +29,7 @@
"helmet": "^8.1.0", "helmet": "^8.1.0",
"jsdom": "^22.1.0", "jsdom": "^22.1.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"jszip": "^3.10.1",
"ms": "^2.1.3", "ms": "^2.1.3",
"multer": "^2.0.2", "multer": "^2.0.2",
"prisma": "^5.22.0", "prisma": "^5.22.0",
@@ -3150,6 +3151,12 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -3371,6 +3378,48 @@
"npm": ">=6" "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": { "node_modules/jwa": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@@ -3434,6 +3483,15 @@
"safe-buffer": "~5.1.0" "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": { "node_modules/lodash": {
"version": "4.17.23", "version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
@@ -3880,6 +3938,12 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0" "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": { "node_modules/parse5": {
"version": "7.3.0", "version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@@ -4393,6 +4457,12 @@
"node": ">= 18" "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": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+2
View File
@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"predev": "node scripts/predev-migrate.cjs", "predev": "node scripts/predev-migrate.cjs",
"dev": "nodemon src/index.ts", "dev": "nodemon src/index.ts",
"admin:recover": "node scripts/admin-recover.cjs",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage" "test:coverage": "vitest run --coverage"
@@ -35,6 +36,7 @@
"helmet": "^8.1.0", "helmet": "^8.1.0",
"jsdom": "^22.1.0", "jsdom": "^22.1.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"jszip": "^3.10.1",
"ms": "^2.1.3", "ms": "^2.1.3",
"multer": "^2.0.2", "multer": "^2.0.2",
"prisma": "^5.22.0", "prisma": "^5.22.0",
@@ -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;
+8 -5
View File
@@ -31,11 +31,14 @@ model User {
} }
model SystemConfig { model SystemConfig {
id String @id @default("default") id String @id @default("default")
authEnabled Boolean @default(false) authEnabled Boolean @default(false)
registrationEnabled Boolean @default(false) registrationEnabled Boolean @default(false)
createdAt DateTime @default(now()) authLoginRateLimitEnabled Boolean @default(true)
updatedAt DateTime @updatedAt authLoginRateLimitWindowMs Int @default(900000) // 15 minutes
authLoginRateLimitMax Int @default(20)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }
model Collection { model Collection {
+183
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+730 -205
View File
File diff suppressed because it is too large Load Diff
+34 -1
View File
@@ -89,6 +89,7 @@ declare global {
name: string; name: string;
role: string; role: string;
mustResetPassword?: boolean; mustResetPassword?: boolean;
impersonatorId?: string;
}; };
} }
} }
@@ -98,6 +99,7 @@ interface JwtPayload {
userId: string; userId: string;
email: string; email: string;
type: "access" | "refresh"; type: "access" | "refresh";
impersonatorId?: string;
} }
/** /**
@@ -108,10 +110,13 @@ const isJwtPayload = (decoded: unknown): decoded is JwtPayload => {
return false; return false;
} }
const payload = decoded as Record<string, unknown>; const payload = decoded as Record<string, unknown>;
const impersonatorOk =
typeof payload.impersonatorId === "undefined" || typeof payload.impersonatorId === "string";
return ( return (
typeof payload.userId === "string" && typeof payload.userId === "string" &&
typeof payload.email === "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 * Require authentication middleware
* Protects routes that require a valid JWT token * Protects routes that require a valid JWT token
@@ -224,6 +246,15 @@ export const requireAuth = async (
return; 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 // Attach user to request
req.user = { req.user = {
id: user.id, id: user.id,
@@ -232,6 +263,7 @@ export const requireAuth = async (
name: user.name, name: user.name,
role: user.role, role: user.role,
mustResetPassword: user.mustResetPassword, mustResetPassword: user.mustResetPassword,
impersonatorId: payload.impersonatorId,
}; };
next(); next();
@@ -297,6 +329,7 @@ export const optionalAuth = async (
name: user.name, name: user.name,
role: user.role, role: user.role,
mustResetPassword: user.mustResetPassword, mustResetPassword: user.mustResetPassword,
impersonatorId: payload.impersonatorId,
}; };
} }
} catch (error) { } catch (error) {
+9
View File
@@ -3,6 +3,7 @@ import { Dashboard } from './pages/Dashboard';
import { Editor } from './pages/Editor'; import { Editor } from './pages/Editor';
import { Settings } from './pages/Settings'; import { Settings } from './pages/Settings';
import { Profile } from './pages/Profile'; import { Profile } from './pages/Profile';
import { Admin } from './pages/Admin';
import { Login } from './pages/Login'; import { Login } from './pages/Login';
import { Register } from './pages/Register'; import { Register } from './pages/Register';
import { PasswordResetRequest } from './pages/PasswordResetRequest'; import { PasswordResetRequest } from './pages/PasswordResetRequest';
@@ -55,6 +56,14 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/admin"
element={
<ProtectedRoute>
<Admin />
</ProtectedRoute>
}
/>
<Route <Route
path="/editor/:id" path="/editor/:id"
element={ element={
+18
View File
@@ -105,6 +105,24 @@ api.interceptors.request.use(
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
async (error) => { 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) // Handle 401 Unauthorized (invalid/expired JWT)
if (error.response?.status === 401) { if (error.response?.status === 401) {
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY); const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
+3 -3
View File
@@ -5,7 +5,7 @@ import { AlertTriangle, CheckCircle, X } from 'lucide-react';
interface ConfirmModalProps { interface ConfirmModalProps {
isOpen: boolean; isOpen: boolean;
title: string; title: string;
message: string; message: React.ReactNode;
confirmText?: string; confirmText?: string;
cancelText?: string; cancelText?: string;
onConfirm: () => void; onConfirm: () => void;
@@ -60,9 +60,9 @@ export const ConfirmModal: React.FC<ConfirmModalProps> = ({
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-xl font-bold text-neutral-900 dark:text-neutral-100 tracking-tight">{title}</h3> <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} {message}
</p> </div>
</div> </div>
<div className="flex gap-3 w-full mt-2"> <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>
);
};
+111 -29
View File
@@ -1,7 +1,10 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { Menu, X } from 'lucide-react';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { UploadStatus } from './UploadStatus'; import { UploadStatus } from './UploadStatus';
import type { Collection } from '../types'; import type { Collection } from '../types';
import clsx from 'clsx';
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
@@ -24,8 +27,11 @@ export const Layout: React.FC<LayoutProps> = ({
onDeleteCollection, onDeleteCollection,
onDrop onDrop
}) => { }) => {
const location = useLocation();
const [sidebarWidth, setSidebarWidth] = useState(260); const [sidebarWidth, setSidebarWidth] = useState(260);
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const sidebarRef = useRef<HTMLDivElement>(null); const sidebarRef = useRef<HTMLDivElement>(null);
const startXRef = useRef(0); const startXRef = useRef(0);
const startWidthRef = 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 ( return (
<div className="h-screen w-full bg-[#F3F4F6] dark:bg-neutral-950 p-4 transition-colors duration-200 overflow-hidden"> <div className="h-screen w-full bg-[#F3F4F6] dark:bg-neutral-950 p-2 sm:p-4 transition-colors duration-200 overflow-hidden">
<div className="flex gap-4 items-start h-full"> {isMobile ? (
<aside <div className="relative h-full min-w-0">
ref={sidebarRef} <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">
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" <div className="px-3 pt-3 flex-shrink-0">
style={{ width: `${sidebarWidth}px` }} <button
> type="button"
<Sidebar onClick={() => setIsSidebarOpen(v => !v)}
collections={collections} 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"
selectedCollectionId={selectedCollectionId} title={isSidebarOpen ? 'Close menu' : 'Open menu'}
onSelectCollection={onSelectCollection} aria-label={isSidebarOpen ? 'Close menu' : 'Open menu'}
onCreateCollection={onCreateCollection} >
onEditCollection={onEditCollection} {isSidebarOpen ? <X size={20} /> : <Menu size={20} />}
onDeleteCollection={onDeleteCollection} </button>
onDrop={onDrop} </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={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)}
/> />
{/* Resize Handle */} <aside
<div ref={sidebarRef}
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`} className={clsx(
onMouseDown={handleMouseDown} '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',
title="Drag to resize sidebar" 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" /> <Sidebar
</div> collections={collections}
</aside> selectedCollectionId={selectedCollectionId}
<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"> onSelectCollection={onSelectCollection}
<div className="max-w-[1600px] mx-auto p-6 lg:p-8 min-h-full"> onCreateCollection={onCreateCollection}
{children} onEditCollection={onEditCollection}
</div> onDeleteCollection={onDeleteCollection}
</main> onDrop={onDrop}
</div> />
<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 /> <UploadStatus />
</div> </div>
); );
+8 -2
View File
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Navigate } from 'react-router-dom'; import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
interface ProtectedRouteProps { interface ProtectedRouteProps {
@@ -7,7 +7,8 @@ interface ProtectedRouteProps {
} }
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => { 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) { if (loading || authEnabled === null) {
return ( return (
@@ -30,5 +31,10 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
return <Navigate to="/login" replace />; 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}</>; return <>{children}</>;
}; };
+68 -6
View File
@@ -1,11 +1,13 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { LayoutGrid, Folder, Plus, Trash2, Edit2, Archive, FolderOpen, Settings as SettingsIcon, User, LogOut } 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 type { Collection } from '../types';
import clsx from 'clsx'; import clsx from 'clsx';
import { ConfirmModal } from './ConfirmModal'; import { ConfirmModal } from './ConfirmModal';
import { Logo } from './Logo'; import { Logo } from './Logo';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { FingerprintAvatar } from './FingerprintAvatar';
import { readImpersonationState, stopImpersonation as restoreImpersonation, type ImpersonationState } from '../utils/impersonation';
interface SidebarProps { interface SidebarProps {
collections: Collection[]; collections: Collection[];
@@ -123,6 +125,8 @@ export const Sidebar: React.FC<SidebarProps> = ({
}) => { }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { logout, user, authEnabled } = useAuth(); const { logout, user, authEnabled } = useAuth();
const isAdmin = user?.role === 'ADMIN';
const [impersonation, setImpersonation] = useState<ImpersonationState | null>(null);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [newCollectionName, setNewCollectionName] = useState(''); const [newCollectionName, setNewCollectionName] = useState('');
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
@@ -137,6 +141,17 @@ export const Sidebar: React.FC<SidebarProps> = ({
return () => document.removeEventListener('click', handleClickOutside); 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) => { const handleCreateSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -169,7 +184,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
return ( return (
<> <>
<div className="w-full flex flex-col h-full bg-transparent"> <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' }}> <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" /> <Logo className="w-10 h-10" />
<span className="mt-1">ExcaliDash</span> <span className="mt-1">ExcaliDash</span>
@@ -178,7 +193,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
</div> </div>
<nav <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} onContextMenu={handleBackgroundContextMenu}
> >
<div className="space-y-1"> <div className="space-y-1">
@@ -260,7 +275,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
</div> </div>
</nav> </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 <button
onDragOver={(e) => { onDragOver={(e) => {
e.preventDefault(); e.preventDefault();
@@ -301,6 +316,21 @@ export const Sidebar: React.FC<SidebarProps> = ({
</button> </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 <button
onClick={() => navigate('/settings')} onClick={() => navigate('/settings')}
className={clsx( className={clsx(
@@ -317,10 +347,42 @@ export const Sidebar: React.FC<SidebarProps> = ({
{/* User info and logout */} {/* User info and logout */}
{authEnabled && ( {authEnabled && (
<div className="mt-auto pt-4 border-t-2 border-slate-200 dark:border-neutral-700"> <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 && ( {user && (
<div className="px-3 py-2 text-xs text-slate-500 dark:text-neutral-500 mb-2"> <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="flex items-center gap-3">
<div className="truncate">{user.email}</div> <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> </div>
)} )}
<button <button
File diff suppressed because it is too large Load Diff
+11 -10
View File
@@ -674,12 +674,12 @@ export const Dashboard: React.FC = () => {
</DragOverlayPortal> </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} {viewTitle}
</h1> </h1>
<div className="mb-8 flex flex-col xl:flex-row items-center justify-between gap-4"> <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"> <div className="relative flex-1 group max-w-md transition-all duration-200 focus-within:-translate-y-0.5">
<input <input
ref={searchInputRef} ref={searchInputRef}
@@ -696,7 +696,7 @@ export const Dashboard: React.FC = () => {
</kbd> </kbd>
</div> </div>
</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"> <div className="relative">
<button <button
onClick={(e) => { onClick={(e) => {
@@ -704,7 +704,7 @@ export const Dashboard: React.FC = () => {
setShowSortMenu(!showSortMenu); setShowSortMenu(!showSortMenu);
}} }}
className={clsx( 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" "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 && ( {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="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"> <div className="bg-indigo-50 p-6 sm:p-8 rounded-full mb-5 sm:mb-6 shadow-sm">
<Inbox size={64} className="text-indigo-600" /> <Inbox size={56} className="text-indigo-600 hidden sm:block" />
<Inbox size={44} className="text-indigo-600 sm:hidden" />
</div> </div>
<h3 className="text-3xl font-bold text-slate-800 mb-2">Drop files to import</h3> <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-lg max-w-md text-center"> <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 Drop .excalidraw or .json files here to add them to
<span className="font-bold text-indigo-600 mx-1"> <span className="font-bold text-indigo-600 mx-1">
{viewTitle} {viewTitle}
@@ -932,9 +933,9 @@ export const Dashboard: React.FC = () => {
<Loader2 size={32} className="animate-spin" /> <Loader2 size={32} className="animate-spin" />
</div> </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 ? ( {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"> <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" />} {isTrashView ? <Trash2 size={32} className="text-slate-300 dark:text-slate-600" /> : <Inbox size={32} className="text-slate-300 dark:text-slate-600" />}
</div> </div>
+175 -55
View File
@@ -1,15 +1,22 @@
import React, { useEffect, useState } from 'react'; 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 { useAuth } from '../context/AuthContext';
import { Logo } from '../components/Logo'; 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 = () => { export const Login: React.FC = () => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmNewPassword, setConfirmNewPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); 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 navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryMustReset = searchParams.get('mustReset') === '1';
const mustReset = Boolean(user?.mustResetPassword) || queryMustReset;
useEffect(() => { useEffect(() => {
if (authLoading || authEnabled === null) return; if (authLoading || authEnabled === null) return;
@@ -22,9 +29,10 @@ export const Login: React.FC = () => {
return; return;
} }
if (isAuthenticated) { if (isAuthenticated) {
if (mustReset) return;
navigate('/', { replace: true }); navigate('/', { replace: true });
} }
}, [authEnabled, authLoading, bootstrapRequired, isAuthenticated, navigate]); }, [authEnabled, authLoading, bootstrapRequired, isAuthenticated, mustReset, navigate]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -33,6 +41,12 @@ export const Login: React.FC = () => {
try { try {
await login(email, password); 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('/'); navigate('/');
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to login'; 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 ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4"> <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="max-w-md w-full space-y-8">
<div className="text-center"> <div className="text-center">
<Logo className="mx-auto h-12 w-auto" /> <Logo className="mx-auto h-12 w-auto" />
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white"> <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> </h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400"> {!mustReset ? (
Or{' '} <p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
<Link Or{' '}
to="/register" <Link
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400" to="/register"
> className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
create a new account >
</Link> create a new account
</p> </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> </div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}> <form className="mt-8 space-y-6" onSubmit={mustReset ? handleMustReset : handleSubmit}>
{error && ( {error && (
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4"> <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="text-sm text-red-800 dark:text-red-200">{error}</div>
</div> </div>
)} )}
<div className="rounded-md shadow-sm -space-y-px"> <div className="rounded-md shadow-sm -space-y-px">
<div> {!mustReset ? (
<label htmlFor="email" className="sr-only"> <>
Email address <div>
</label> <label htmlFor="email" className="sr-only">
<input Email address
id="email" </label>
name="email" <input
type="email" id="email"
autoComplete="email" name="email"
required type="email"
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" autoComplete="email"
placeholder="Email address" required
value={email} 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"
onChange={(e) => setEmail(e.target.value)} placeholder="Email address"
/> value={email}
</div> onChange={(e) => setEmail(e.target.value)}
<div> />
<label htmlFor="password" className="sr-only"> </div>
Password <div>
</label> <label htmlFor="password" className="sr-only">
<input Password
id="password" </label>
name="password" <input
type="password" id="password"
autoComplete="current-password" name="password"
required type="password"
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" autoComplete="current-password"
placeholder="Password" required
value={password} 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"
onChange={(e) => setPassword(e.target.value)} placeholder="Password"
/> value={password}
</div> 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>
<div className="flex justify-end"> {!mustReset && (
<Link <div className="flex justify-end">
to="/reset-password" <Link
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400" to="/reset-password"
> className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
Forgot your password? >
</Link> Forgot your password?
</div> </Link>
</div>
)}
<div> <div>
<button <button
@@ -116,9 +220,25 @@ export const Login: React.FC = () => {
disabled={loading} 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" 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> </button>
</div> </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> </form>
</div> </div>
</div> </div>
+13 -95
View File
@@ -1,112 +1,30 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import axios from 'axios';
import { Logo } from '../components/Logo'; import { Logo } from '../components/Logo';
const API_URL = import.meta.env.VITE_API_URL || "/api";
export const PasswordResetRequest: React.FC = () => { 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 ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4"> <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="max-w-md w-full space-y-8">
<div className="text-center"> <div className="text-center">
<Logo className="mx-auto h-12 w-auto" /> <Logo className="mx-auto h-12 w-auto" />
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white"> <h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
Reset your password Password help
</h2> </h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400"> <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> </p>
</div> </div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}> <div className="mt-8 space-y-6">
{error && ( <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="rounded-md bg-red-50 dark:bg-red-900/20 p-4"> <div className="text-sm text-gray-700 dark:text-gray-200">
<div className="text-sm text-red-800 dark:text-red-200">{error}</div> Contact your administrator and ask them to generate a temporary password from the Admin dashboard.
</div> </div>
)} <div className="text-xs text-gray-600 dark:text-gray-300">
<div> If you are an admin and youre locked out, run:
<label htmlFor="email" className="sr-only"> </div>
Email address <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">
</label> cd backend && node scripts/admin-recover.cjs --identifier you@example.com --generate --activate --disable-login-rate-limit
<input </pre>
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> </div>
<div className="text-center"> <div className="text-center">
@@ -117,7 +35,7 @@ export const PasswordResetRequest: React.FC = () => {
Back to login Back to login
</Link> </Link>
</div> </div>
</form> </div>
</div> </div>
</div> </div>
); );
+185 -41
View File
@@ -5,11 +5,13 @@ import { useAuth } from '../context/AuthContext';
import * as api from '../api'; import * as api from '../api';
import type { Collection } from '../types'; import type { Collection } from '../types';
import { User, Lock, Save, X, Shield } from 'lucide-react'; 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 = () => { export const Profile: React.FC = () => {
const { user: authUser, logout, authEnabled } = useAuth(); const { user: authUser, logout, authEnabled } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const isAdmin = authUser?.role === 'ADMIN'; const isAdmin = authUser?.role === 'ADMIN';
const mustResetPassword = Boolean(authUser?.mustResetPassword);
const [collections, setCollections] = useState<Collection[]>([]); const [collections, setCollections] = useState<Collection[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -20,6 +22,9 @@ export const Profile: React.FC = () => {
// User info state // User info state
const [name, setName] = useState(''); const [name, setName] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [showEmailForm, setShowEmailForm] = useState(false);
const [emailCurrentPassword, setEmailCurrentPassword] = useState('');
const [emailLoading, setEmailLoading] = useState(false);
// Password change state // Password change state
const [currentPassword, setCurrentPassword] = useState(''); const [currentPassword, setCurrentPassword] = useState('');
@@ -56,6 +61,12 @@ export const Profile: React.FC = () => {
fetchData(); fetchData();
}, [authEnabled, authUser, isAdmin, navigate]); }, [authEnabled, authUser, isAdmin, navigate]);
useEffect(() => {
if (mustResetPassword) {
setShowPasswordForm(true);
}
}, [mustResetPassword]);
const handleToggleRegistration = async () => { const handleToggleRegistration = async () => {
if (!isAdmin || registrationEnabled === null) return; if (!isAdmin || registrationEnabled === null) return;
@@ -107,6 +118,10 @@ export const Profile: React.FC = () => {
}; };
const handleUpdateName = async () => { const handleUpdateName = async () => {
if (mustResetPassword) {
setError('You must reset your password before updating your profile');
return;
}
if (!name.trim()) { if (!name.trim()) {
setError('Name cannot be empty'); setError('Name cannot be empty');
return; 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 ( return (
<Layout <Layout
collections={collections} collections={collections}
@@ -200,7 +267,7 @@ export const Profile: React.FC = () => {
onEditCollection={handleEditCollection} onEditCollection={handleEditCollection}
onDeleteCollection={handleDeleteCollection} 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 Profile
</h1> </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> <h2 className="text-2xl font-bold text-slate-900 dark:text-white">Personal Information</h2>
</div> </div>
<div className="space-y-4"> {mustResetPassword && (
<div> <div className="p-4 bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-200 dark:border-amber-800 rounded-xl">
<label htmlFor="email" className="block text-sm font-bold text-slate-700 dark:text-neutral-300 mb-2"> <p className="text-amber-900 dark:text-amber-200 font-bold">
Email Address Password reset required
</label> </p>
<input <p className="text-sm text-amber-800 dark:text-amber-200/80 font-medium mt-1">
id="email" Change your password below before using ExcaliDash.
type="email" </p>
value={email} </div>
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" <div className="space-y-4">
/> <div>
<p className="mt-1 text-xs text-slate-500 dark:text-neutral-500">Email cannot be changed</p> <label htmlFor="email" className="block text-sm font-bold text-slate-700 dark:text-neutral-300 mb-2">
</div> 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> <div>
<label htmlFor="name" className="block text-sm font-bold text-slate-700 dark:text-neutral-300 mb-2"> <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" 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" placeholder="Your name"
/> />
<button <button
onClick={handleUpdateName} onClick={handleUpdateName}
disabled={loading || !name.trim() || name === authUser?.name} 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" 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 size={18} />
Save Save
</button> </button>
</div> </div>
</div> </div>
</div> </div>
@@ -312,7 +454,7 @@ export const Profile: React.FC = () => {
</div> </div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Change Password</h2> <h2 className="text-2xl font-bold text-slate-900 dark:text-white">Change Password</h2>
</div> </div>
{!showPasswordForm && ( {!showPasswordForm && !mustResetPassword && (
<button <button
onClick={() => setShowPasswordForm(true)} 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" 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'} {loading ? 'Changing...' : 'Change Password'}
</button> </button>
<button {!mustResetPassword && (
onClick={() => { <button
setShowPasswordForm(false); onClick={() => {
setCurrentPassword(''); setShowPasswordForm(false);
setNewPassword(''); setCurrentPassword('');
setConfirmPassword(''); setNewPassword('');
setError(''); 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" 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 <X size={18} />
</button> Cancel
</div> </button>
</div> )}
)} </div>
</div>
)}
</div> </div>
</div> </div>
</Layout> </Layout>
File diff suppressed because it is too large Load Diff
+47
View File
@@ -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;
};
+220
View File
@@ -2,6 +2,43 @@ import { exportToSvg } from "@excalidraw/excalidraw";
import { api } from "../api"; import { api } from "../api";
import { type UploadStatus } from "../context/UploadContext"; 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 ( export const importDrawings = async (
files: File[], files: File[],
targetCollectionId: string | null, targetCollectionId: string | null,
@@ -109,3 +146,186 @@ export const importDrawings = async (
return { success: successCount, failed: failCount, errors }; 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 };
};