Ensure non multi-user flow stays
This commit is contained in:
@@ -11,9 +11,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import bcrypt from "bcrypt";
|
||||
import {
|
||||
getTestPrisma,
|
||||
cleanupTestDb,
|
||||
setupTestDb,
|
||||
createTestDrawingPayload,
|
||||
} from "./testUtils";
|
||||
import { PrismaClient } from "../generated/client";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import express, { Request, Response } from "express";
|
||||
import bcrypt from "bcrypt";
|
||||
import jwt, { SignOptions } from "jsonwebtoken";
|
||||
import { PrismaClient, Prisma } from "../generated/client";
|
||||
import { PrismaClient } from "../generated/client";
|
||||
import { StringValue } from "ms";
|
||||
import { logAuditEvent } from "../utils/audit";
|
||||
import {
|
||||
|
||||
@@ -24,14 +24,6 @@ interface Config {
|
||||
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 => {
|
||||
return process.env[key] || defaultValue;
|
||||
};
|
||||
|
||||
@@ -908,7 +908,6 @@ registerImportExportRoutes({
|
||||
asyncHandler,
|
||||
upload,
|
||||
uploadDir,
|
||||
config,
|
||||
backendRoot,
|
||||
getBackendVersion,
|
||||
parseJsonField,
|
||||
|
||||
@@ -13,7 +13,7 @@ type AuthEnabledCache = {
|
||||
};
|
||||
|
||||
let authEnabledCache: AuthEnabledCache | null = null;
|
||||
const AUTH_ENABLED_TTL_MS = 0;
|
||||
const AUTH_ENABLED_TTL_MS = 5000;
|
||||
|
||||
const getAuthEnabled = async (): Promise<boolean> => {
|
||||
const now = Date.now();
|
||||
@@ -21,16 +21,32 @@ const getAuthEnabled = async (): Promise<boolean> => {
|
||||
return authEnabledCache.value;
|
||||
}
|
||||
|
||||
const systemConfig = await prisma.systemConfig.upsert({
|
||||
let systemConfig = await prisma.systemConfig.findUnique({
|
||||
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
|
||||
update: {},
|
||||
create: {
|
||||
select: { authEnabled: true },
|
||||
});
|
||||
|
||||
if (!systemConfig) {
|
||||
try {
|
||||
systemConfig = await prisma.systemConfig.create({
|
||||
data: {
|
||||
id: DEFAULT_SYSTEM_CONFIG_ID,
|
||||
authEnabled: false,
|
||||
registrationEnabled: false,
|
||||
},
|
||||
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 };
|
||||
return systemConfig.authEnabled;
|
||||
|
||||
@@ -211,17 +211,15 @@ export const registerDashboardRoutes = (
|
||||
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
|
||||
|
||||
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) {
|
||||
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({
|
||||
...drawing,
|
||||
|
||||
@@ -56,7 +56,6 @@ type RegisterImportExportDeps = {
|
||||
) => express.RequestHandler;
|
||||
upload: any;
|
||||
uploadDir: string;
|
||||
config: { nodeEnv: string };
|
||||
backendRoot: string;
|
||||
getBackendVersion: () => string;
|
||||
parseJsonField: <T>(rawValue: string | null | undefined, fallback: T) => T;
|
||||
@@ -231,7 +230,6 @@ export const registerImportExportRoutes = (deps: RegisterImportExportDeps) => {
|
||||
asyncHandler,
|
||||
upload,
|
||||
uploadDir,
|
||||
config,
|
||||
backendRoot,
|
||||
getBackendVersion,
|
||||
parseJsonField,
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
@@ -12,16 +12,6 @@ import { createDrawing, deleteDrawing, getDrawing, updateDrawing } from "./helpe
|
||||
* 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 page = await context.newPage();
|
||||
await page.goto(`/editor/${drawingId}`);
|
||||
@@ -249,4 +239,3 @@ test.describe("Issue #25 - image sync + deletion across tabs", () => {
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ConfirmModal } from './ConfirmModal';
|
||||
import { Logo } from './Logo';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { readImpersonationState, stopImpersonation as restoreImpersonation, type ImpersonationState } from '../utils/impersonation';
|
||||
import { getInitialsFromName } from '../utils/user';
|
||||
|
||||
interface SidebarProps {
|
||||
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> = ({
|
||||
collections,
|
||||
selectedCollectionId,
|
||||
|
||||
@@ -62,15 +62,7 @@ export const UIOptions = {
|
||||
},
|
||||
};
|
||||
|
||||
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();
|
||||
};
|
||||
export { getInitialsFromName } from "../../utils/user";
|
||||
|
||||
export const getColorFromString = (str: string): string => {
|
||||
const COLORS = [
|
||||
|
||||
@@ -106,7 +106,13 @@ const getSecureRandomInt = (maxExclusive: number): number => {
|
||||
cryptoObj.getRandomValues(buffer);
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -129,7 +135,13 @@ const generateClientId = (): string => {
|
||||
}
|
||||
|
||||
// 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)}`;
|
||||
};
|
||||
|
||||
@@ -152,12 +164,30 @@ export const getFingerprintInitials = (seed?: string): string => {
|
||||
export const getUserIdentity = (): UserIdentity => {
|
||||
const stored = localStorage.getItem("excalidash-user-id");
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as UserIdentity;
|
||||
if (!parsed.initials || parsed.initials.length !== 2) {
|
||||
parsed.initials = getFingerprintInitials(parsed.id);
|
||||
localStorage.setItem("excalidash-user-id", JSON.stringify(parsed));
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as Partial<UserIdentity>;
|
||||
if (
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
Reference in New Issue
Block a user