Ensure non multi-user flow stays

This commit is contained in:
Zimeng Xiong
2026-02-06 23:05:23 -08:00
parent 7aa33a1bdf
commit f214e4f7b7
12 changed files with 80 additions and 70 deletions
@@ -11,9 +11,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import { import {
getTestPrisma, getTestPrisma,
cleanupTestDb,
setupTestDb, setupTestDb,
createTestDrawingPayload,
} from "./testUtils"; } from "./testUtils";
import { PrismaClient } from "../generated/client"; import { PrismaClient } from "../generated/client";
+1 -1
View File
@@ -1,7 +1,7 @@
import express, { Request, Response } from "express"; import express, { Request, Response } from "express";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import jwt, { SignOptions } from "jsonwebtoken"; import jwt, { SignOptions } from "jsonwebtoken";
import { PrismaClient, Prisma } from "../generated/client"; import { PrismaClient } from "../generated/client";
import { StringValue } from "ms"; import { StringValue } from "ms";
import { logAuditEvent } from "../utils/audit"; import { logAuditEvent } from "../utils/audit";
import { import {
-8
View File
@@ -24,14 +24,6 @@ interface Config {
enableAuditLogging: boolean; enableAuditLogging: boolean;
} }
const getRequiredEnv = (key: string): string => {
const value = process.env[key];
if (!value || value.trim().length === 0) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
};
const getOptionalEnv = (key: string, defaultValue: string): string => { const getOptionalEnv = (key: string, defaultValue: string): string => {
return process.env[key] || defaultValue; return process.env[key] || defaultValue;
}; };
-1
View File
@@ -908,7 +908,6 @@ registerImportExportRoutes({
asyncHandler, asyncHandler,
upload, upload,
uploadDir, uploadDir,
config,
backendRoot, backendRoot,
getBackendVersion, getBackendVersion,
parseJsonField, parseJsonField,
+20 -4
View File
@@ -13,7 +13,7 @@ type AuthEnabledCache = {
}; };
let authEnabledCache: AuthEnabledCache | null = null; let authEnabledCache: AuthEnabledCache | null = null;
const AUTH_ENABLED_TTL_MS = 0; const AUTH_ENABLED_TTL_MS = 5000;
const getAuthEnabled = async (): Promise<boolean> => { const getAuthEnabled = async (): Promise<boolean> => {
const now = Date.now(); const now = Date.now();
@@ -21,16 +21,32 @@ const getAuthEnabled = async (): Promise<boolean> => {
return authEnabledCache.value; return authEnabledCache.value;
} }
const systemConfig = await prisma.systemConfig.upsert({ let systemConfig = await prisma.systemConfig.findUnique({
where: { id: DEFAULT_SYSTEM_CONFIG_ID }, where: { id: DEFAULT_SYSTEM_CONFIG_ID },
update: {}, select: { authEnabled: true },
create: { });
if (!systemConfig) {
try {
systemConfig = await prisma.systemConfig.create({
data: {
id: DEFAULT_SYSTEM_CONFIG_ID, id: DEFAULT_SYSTEM_CONFIG_ID,
authEnabled: false, authEnabled: false,
registrationEnabled: false, registrationEnabled: false,
}, },
select: { authEnabled: true }, select: { authEnabled: true },
}); });
} catch {
// Handle race from concurrent initialization.
systemConfig = await prisma.systemConfig.findUnique({
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
select: { authEnabled: true },
});
if (!systemConfig) {
throw new Error("Failed to initialize system config");
}
}
}
authEnabledCache = { value: systemConfig.authEnabled, fetchedAt: now }; authEnabledCache = { value: systemConfig.authEnabled, fetchedAt: now };
return systemConfig.authEnabled; return systemConfig.authEnabled;
+6 -8
View File
@@ -211,17 +211,15 @@ export const registerDashboardRoutes = (
if (!req.user) return res.status(401).json({ error: "Unauthorized" }); if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const { id } = req.params; const { id } = req.params;
const drawing = await prisma.drawing.findUnique({ where: { id } }); const drawing = await prisma.drawing.findFirst({
where: {
id,
userId: req.user.id,
},
});
if (!drawing) { if (!drawing) {
return res.status(404).json({ error: "Drawing not found", message: "Drawing does not exist" }); return res.status(404).json({ error: "Drawing not found", message: "Drawing does not exist" });
} }
if (drawing.userId !== req.user.id) {
return res.status(403).json({
error: "Forbidden",
code: "DRAWING_ACCESS_DENIED",
message: "You do not have access to this drawing",
});
}
return res.json({ return res.json({
...drawing, ...drawing,
-2
View File
@@ -56,7 +56,6 @@ type RegisterImportExportDeps = {
) => express.RequestHandler; ) => express.RequestHandler;
upload: any; upload: any;
uploadDir: string; uploadDir: string;
config: { nodeEnv: string };
backendRoot: string; backendRoot: string;
getBackendVersion: () => string; getBackendVersion: () => string;
parseJsonField: <T>(rawValue: string | null | undefined, fallback: T) => T; parseJsonField: <T>(rawValue: string | null | undefined, fallback: T) => T;
@@ -231,7 +230,6 @@ export const registerImportExportRoutes = (deps: RegisterImportExportDeps) => {
asyncHandler, asyncHandler,
upload, upload,
uploadDir, uploadDir,
config,
backendRoot, backendRoot,
getBackendVersion, getBackendVersion,
parseJsonField, parseJsonField,
+1 -12
View File
@@ -1,4 +1,4 @@
import { test, expect, type Page, type BrowserContext } from "@playwright/test"; import { test, expect, type BrowserContext } from "@playwright/test";
import { createDrawing, deleteDrawing, getDrawing, updateDrawing } from "./helpers/api"; import { createDrawing, deleteDrawing, getDrawing, updateDrawing } from "./helpers/api";
/** /**
@@ -12,16 +12,6 @@ import { createDrawing, deleteDrawing, getDrawing, updateDrawing } from "./helpe
* file data later" behavior seen with paste/import. * file data later" behavior seen with paste/import.
*/ */
const waitForEditorReady = async (page: Page) => {
await page.goto(page.url() || "/");
// Excalidraw renders a canvas; this is our "loaded" signal.
await page.waitForSelector("[class*='excalidraw'], canvas", { timeout: 15000 });
await page.waitForFunction(() => {
// @ts-expect-error - injected in dev build
return !!(window as any).__EXCALIDASH_EXCALIDRAW_API__;
});
};
const openEditorTab = async (context: BrowserContext, drawingId: string) => { const openEditorTab = async (context: BrowserContext, drawingId: string) => {
const page = await context.newPage(); const page = await context.newPage();
await page.goto(`/editor/${drawingId}`); await page.goto(`/editor/${drawingId}`);
@@ -249,4 +239,3 @@ test.describe("Issue #25 - image sync + deletion across tabs", () => {
await context.close(); await context.close();
}); });
}); });
+1 -12
View File
@@ -7,6 +7,7 @@ import { ConfirmModal } from './ConfirmModal';
import { Logo } from './Logo'; import { Logo } from './Logo';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { readImpersonationState, stopImpersonation as restoreImpersonation, type ImpersonationState } from '../utils/impersonation'; import { readImpersonationState, stopImpersonation as restoreImpersonation, type ImpersonationState } from '../utils/impersonation';
import { getInitialsFromName } from '../utils/user';
interface SidebarProps { interface SidebarProps {
collections: Collection[]; collections: Collection[];
@@ -111,18 +112,6 @@ const SidebarItem: React.FC<SidebarItemProps> = ({
); );
}; };
const getInitialsFromName = (name: string): string => {
const trimmed = name.trim();
if (!trimmed) return 'U';
const parts = trimmed.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
}
return trimmed.slice(0, 2).toUpperCase();
};
export const Sidebar: React.FC<SidebarProps> = ({ export const Sidebar: React.FC<SidebarProps> = ({
collections, collections,
selectedCollectionId, selectedCollectionId,
+1 -9
View File
@@ -62,15 +62,7 @@ export const UIOptions = {
}, },
}; };
export const getInitialsFromName = (name: string): string => { export { getInitialsFromName } from "../../utils/user";
const trimmed = name.trim();
if (!trimmed) return 'U';
const parts = trimmed.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
}
return trimmed.slice(0, 2).toUpperCase();
};
export const getColorFromString = (str: string): string => { export const getColorFromString = (str: string): string => {
const COLORS = [ const COLORS = [
+37 -7
View File
@@ -106,7 +106,13 @@ const getSecureRandomInt = (maxExclusive: number): number => {
cryptoObj.getRandomValues(buffer); cryptoObj.getRandomValues(buffer);
return buffer[0] % maxExclusive; return buffer[0] % maxExclusive;
} }
const seed = `${Date.now().toString(16)}:${performance.now().toString(16)}`; const perfNow =
typeof globalThis !== "undefined" &&
typeof globalThis.performance !== "undefined" &&
typeof globalThis.performance.now === "function"
? globalThis.performance.now()
: 0;
const seed = `${Date.now().toString(16)}:${perfNow.toString(16)}`;
return hashString(seed) % maxExclusive; return hashString(seed) % maxExclusive;
}; };
@@ -129,7 +135,13 @@ const generateClientId = (): string => {
} }
// Final fallback for very old browsers; uniqueness window-scoped only. // Final fallback for very old browsers; uniqueness window-scoped only.
const entropy = `${Date.now().toString(16)}-${performance.now().toString(16)}-${getSecureRandomInt(1_000_000_000).toString(16)}`; const perfNow =
typeof globalThis !== "undefined" &&
typeof globalThis.performance !== "undefined" &&
typeof globalThis.performance.now === "function"
? globalThis.performance.now()
: 0;
const entropy = `${Date.now().toString(16)}-${perfNow.toString(16)}-${getSecureRandomInt(1_000_000_000).toString(16)}`;
return `id-${hashString(entropy).toString(16)}-${hashString(`${entropy}:2`).toString(16)}`; return `id-${hashString(entropy).toString(16)}-${hashString(`${entropy}:2`).toString(16)}`;
}; };
@@ -152,12 +164,30 @@ export const getFingerprintInitials = (seed?: string): string => {
export const getUserIdentity = (): UserIdentity => { export const getUserIdentity = (): UserIdentity => {
const stored = localStorage.getItem("excalidash-user-id"); const stored = localStorage.getItem("excalidash-user-id");
if (stored) { if (stored) {
const parsed = JSON.parse(stored) as UserIdentity; try {
if (!parsed.initials || parsed.initials.length !== 2) { const parsed = JSON.parse(stored) as Partial<UserIdentity>;
parsed.initials = getFingerprintInitials(parsed.id); if (
localStorage.setItem("excalidash-user-id", JSON.stringify(parsed)); parsed &&
typeof parsed === "object" &&
typeof parsed.id === "string" &&
typeof parsed.name === "string" &&
typeof parsed.color === "string"
) {
const normalized: UserIdentity = {
id: parsed.id,
name: parsed.name,
color: parsed.color,
initials:
typeof parsed.initials === "string" && parsed.initials.length === 2
? parsed.initials
: getFingerprintInitials(parsed.id),
};
localStorage.setItem("excalidash-user-id", JSON.stringify(normalized));
return normalized;
}
} catch {
// Fall through to regenerate identity.
} }
return parsed;
} }
const deviceId = getOrCreateBrowserFingerprint(); const deviceId = getOrCreateBrowserFingerprint();
+9
View File
@@ -0,0 +1,9 @@
export const getInitialsFromName = (name: string): string => {
const trimmed = name.trim();
if (!trimmed) return "U";
const parts = trimmed.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
}
return trimmed.slice(0, 2).toUpperCase();
};