feat(auth): enhance authentication system with multi-user support and admin role management

- Implemented multi-user authentication with role-based access control.
- Added environment variables for initial admin user setup.
- Updated README and example environment file with new authentication options.
- Introduced user and system configuration models in the database schema.
- Enhanced authentication middleware to support user registration and role management.
- Updated frontend to handle new authentication flows, including admin user creation and role updates.
This commit is contained in:
Adrian Acala
2026-01-18 09:43:32 -08:00
parent 20ef4ee295
commit 1a52fe80f3
27 changed files with 1692 additions and 237 deletions
+12 -4
View File
@@ -156,24 +156,32 @@ Without this, each container generates its own ephemeral CSRF secret, causing to
### Optional Authentication
ExcaliDash can enforce a single username/password to protect the dashboard and API.
Set these backend environment variables to enable it:
ExcaliDash supports multi-user authentication with role-based administration.
The first admin user can be seeded via environment variables, or created in the UI
when no users exist. Set these backend environment variables to bootstrap an admin:
```bash
# Optional (defaults to "admin")
AUTH_USERNAME=admin
# Optional (defaults to empty)
AUTH_EMAIL=admin@example.com
# Optional (if omitted, a secure random password is generated and logged)
AUTH_PASSWORD=change-me
# Recommended: keep sessions stable across restarts
AUTH_SESSION_SECRET=your-random-secret
# Optional (default: 168 hours)
AUTH_SESSION_TTL_HOURS=168
# Optional (default: 7)
AUTH_MIN_PASSWORD_LENGTH=7
# Optional (default: excalidash_auth)
AUTH_COOKIE_NAME=excalidash_auth
# Optional: lax | strict | none (use "none" for cross-site hosting)
AUTH_COOKIE_SAMESITE=lax
```
When enabled, the UI prompts for a login before accessing any drawings,
and all API/WebSocket traffic requires the session cookie.
Once logged in, admins can toggle user registration and grant other admins from
Settings. If no admin credentials are provided, the UI will prompt to create the
first admin account.
# Development
+6
View File
@@ -5,3 +5,9 @@ DATABASE_URL=file:/app/prisma/dev.db
FRONTEND_URL=http://localhost:6767
# Optional auth cookie settings: lax | strict | none
AUTH_COOKIE_SAMESITE=lax
# Optional auth bootstrap (creates initial admin)
AUTH_USERNAME=admin
AUTH_EMAIL=admin@example.com
# If not set, a random password is generated and logged
AUTH_PASSWORD=
AUTH_MIN_PASSWORD_LENGTH=7
@@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"username" TEXT,
"email" TEXT,
"passwordHash" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'USER',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "SystemConfig" (
"id" TEXT NOT NULL PRIMARY KEY DEFAULT 'default',
"registrationEnabled" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
+17
View File
@@ -40,3 +40,20 @@ model Library {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model User {
id String @id @default(uuid())
username String? @unique
email String? @unique
passwordHash String
role String @default("USER")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model SystemConfig {
id String @id @default("default")
registrationEnabled Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -0,0 +1,110 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import request from "supertest";
import {
cleanupTestDb,
getTestDatabaseUrl,
getTestPrisma,
initTestDb,
setupTestDb,
} from "./testUtils";
let prisma = getTestPrisma();
describe("Authentication flows", () => {
let app: any;
beforeAll(async () => {
process.env.DATABASE_URL = getTestDatabaseUrl();
process.env.AUTH_SESSION_SECRET = "test-secret";
process.env.NODE_ENV = "test";
setupTestDb();
prisma = getTestPrisma();
await initTestDb(prisma);
const appModule = await import("../index");
app = appModule.default || appModule.app || appModule;
});
beforeEach(async () => {
await cleanupTestDb(prisma);
await initTestDb(prisma);
});
const fetchCsrfToken = async () => {
const csrf = await request(app).get("/csrf-token");
return csrf.body?.token as string;
};
const createAdminSession = async () => {
let token = await fetchCsrfToken();
const bootstrap = await request(app)
.post("/auth/bootstrap")
.set("x-csrf-token", token)
.send({ username: "admin", password: "password123" });
if (bootstrap.status !== 201) {
throw new Error(`Bootstrap failed: ${bootstrap.status} ${JSON.stringify(bootstrap.body)}`);
}
token = await fetchCsrfToken();
const login = await request(app)
.post("/auth/login")
.set("x-csrf-token", token)
.send({ username: "admin", password: "password123" });
return login.headers["set-cookie"] as string[] | undefined;
};
afterAll(async () => {
await prisma.$disconnect();
});
it("requires bootstrap before registration", async () => {
const token = await fetchCsrfToken();
const response = await request(app)
.post("/auth/register")
.set("x-csrf-token", token)
.send({ username: "user1", password: "password123" });
expect(response.status).toBe(409);
});
it("bootstraps first admin and logs in", async () => {
const cookie = await createAdminSession();
expect(cookie).toBeTruthy();
});
it("toggles registration when admin", async () => {
const cookie = await createAdminSession();
expect(cookie).toBeTruthy();
const token = await fetchCsrfToken();
const toggle = await request(app)
.post("/auth/registration/toggle")
.set("Cookie", cookie)
.set("x-csrf-token", token)
.send({ enabled: true });
expect(toggle.status).toBe(200);
expect(toggle.body.registrationEnabled).toBe(true);
});
it("registers a new user when enabled", async () => {
const cookie = await createAdminSession();
expect(cookie).toBeTruthy();
let token = await fetchCsrfToken();
await request(app)
.post("/auth/registration/toggle")
.set("Cookie", cookie)
.set("x-csrf-token", token)
.send({ enabled: true });
token = await fetchCsrfToken();
const register = await request(app)
.post("/auth/register")
.set("x-csrf-token", token)
.send({ username: "user1", password: "password123" });
expect(register.status).toBe(201);
expect(register.body.user.username).toBe("user1");
});
});
+30 -18
View File
@@ -2,31 +2,36 @@ import { describe, it, expect, vi } from "vitest";
import {
buildAuthConfig,
createAuthSessionToken,
generateRandomPassword,
getAuthSessionFromCookie,
hashPassword,
isPasswordValid,
validateAuthSessionToken,
verifyCredentials,
verifyPassword,
} from "../auth";
describe("Auth utilities", () => {
it("disables auth when credentials are missing", () => {
it("builds auth config defaults", () => {
const config = buildAuthConfig({});
expect(config.enabled).toBe(false);
expect(config.enabled).toBe(true);
expect(config.minPasswordLength).toBe(7);
});
it("verifies credentials and validates issued session tokens", () => {
it("hashes and verifies passwords", () => {
const hashed = hashPassword("super-secret");
expect(verifyPassword("super-secret", hashed)).toBe(true);
expect(verifyPassword("wrong", hashed)).toBe(false);
});
it("validates issued session tokens", () => {
const config = buildAuthConfig({
AUTH_USERNAME: "admin",
AUTH_PASSWORD: "super-secret",
AUTH_SESSION_SECRET: "test-secret",
});
expect(verifyCredentials(config, "admin", "super-secret")).toBe(true);
expect(verifyCredentials(config, "admin", "wrong")).toBe(false);
const token = createAuthSessionToken(config, "admin");
const token = createAuthSessionToken(config, "user-123");
const session = validateAuthSessionToken(config, token);
expect(session).not.toBeNull();
expect(session?.username).toBe("admin");
expect(session?.userId).toBe("user-123");
});
it("rejects expired session tokens", () => {
@@ -34,13 +39,11 @@ describe("Auth utilities", () => {
vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
const config = buildAuthConfig({
AUTH_USERNAME: "admin",
AUTH_PASSWORD: "secret",
AUTH_SESSION_SECRET: "test-secret",
AUTH_SESSION_TTL_HOURS: "0.001", // ~3.6 seconds
});
const token = createAuthSessionToken(config, "admin");
const token = createAuthSessionToken(config, "user-123");
vi.setSystemTime(new Date("2025-01-01T00:00:10.000Z"));
expect(validateAuthSessionToken(config, token)).toBeNull();
@@ -49,14 +52,23 @@ describe("Auth utilities", () => {
it("extracts session tokens from cookies", () => {
const config = buildAuthConfig({
AUTH_USERNAME: "admin",
AUTH_PASSWORD: "secret",
AUTH_SESSION_SECRET: "test-secret",
});
const token = createAuthSessionToken(config, "admin");
const token = createAuthSessionToken(config, "user-123");
const cookieHeader = `${config.cookieName}=${encodeURIComponent(token)}; theme=dark`;
const session = getAuthSessionFromCookie(cookieHeader, config);
expect(session?.username).toBe("admin");
expect(session?.userId).toBe("user-123");
});
it("validates password length", () => {
const config = buildAuthConfig({ AUTH_MIN_PASSWORD_LENGTH: "9" });
expect(isPasswordValid(config, "12345678")).toBe(false);
expect(isPasswordValid(config, "123456789")).toBe(true);
});
it("generates random passwords", () => {
const password = generateRandomPassword(32);
expect(password).toHaveLength(32);
});
});
@@ -314,10 +314,11 @@ describe("Security Sanitization - Image Data URLs", () => {
// Database integration tests
describe("Drawing API - Database Round-Trip", () => {
const prisma = getTestPrisma();
let prisma: ReturnType<typeof getTestPrisma>;
beforeAll(async () => {
setupTestDb();
prisma = getTestPrisma();
await initTestDb(prisma);
});
+29 -7
View File
@@ -5,19 +5,24 @@ import { PrismaClient } from "../generated/client";
import path from "path";
import { execSync } from "child_process";
const testDbSuffix =
process.env.VITEST_POOL_ID || process.env.VITEST_WORKER_ID || String(process.pid);
// Use a separate test database
const TEST_DB_PATH = path.resolve(__dirname, "../../prisma/test.db");
const TEST_DB_PATH = path.resolve(__dirname, `../../prisma/test-${testDbSuffix}.db`);
const TEST_DATABASE_URL = `file:${TEST_DB_PATH}`;
export const getTestDatabaseUrl = () => TEST_DATABASE_URL;
/**
* Get a test Prisma client pointing to the test database
*/
export const getTestPrisma = () => {
const databaseUrl = `file:${TEST_DB_PATH}`;
process.env.DATABASE_URL = databaseUrl;
process.env.DATABASE_URL = TEST_DATABASE_URL;
return new PrismaClient({
datasources: {
db: {
url: databaseUrl,
url: TEST_DATABASE_URL,
},
},
});
@@ -27,14 +32,23 @@ export const getTestPrisma = () => {
* Setup the test database by running migrations
*/
export const setupTestDb = () => {
const databaseUrl = `file:${TEST_DB_PATH}`;
process.env.DATABASE_URL = databaseUrl;
process.env.DATABASE_URL = TEST_DATABASE_URL;
// Remove existing DB to avoid locked state in parallel runs
try {
execSync(`rm -f "${TEST_DB_PATH}"`, {
cwd: path.resolve(__dirname, "../../"),
stdio: "pipe",
});
} catch {
// ignore cleanup failures
}
// Run Prisma migrations to create the test database
try {
execSync("npx prisma db push --skip-generate", {
cwd: path.resolve(__dirname, "../../"),
env: { ...process.env, DATABASE_URL: databaseUrl },
env: { ...process.env, DATABASE_URL: TEST_DATABASE_URL },
stdio: "pipe",
});
} catch (error) {
@@ -52,6 +66,8 @@ export const cleanupTestDb = async (prisma: PrismaClient) => {
await prisma.collection.deleteMany({
where: { id: { not: "trash" } },
});
await prisma.user.deleteMany({});
await prisma.systemConfig.deleteMany({});
};
/**
@@ -67,6 +83,12 @@ export const initTestDb = async (prisma: PrismaClient) => {
data: { id: "trash", name: "Trash" },
});
}
await prisma.systemConfig.upsert({
where: { id: "default" },
update: {},
create: { id: "default", registrationEnabled: false },
});
};
/**
+67 -24
View File
@@ -4,16 +4,15 @@ export type AuthSameSite = "lax" | "strict" | "none";
export type AuthConfig = {
enabled: boolean;
username: string;
password: string;
sessionTtlMs: number;
cookieName: string;
cookieSameSite: AuthSameSite;
secret: Buffer;
minPasswordLength: number;
};
export type AuthSession = {
username: string;
userId: string;
iat: number;
exp: number;
};
@@ -46,6 +45,14 @@ const parseSessionTtlHours = (rawValue?: string): number => {
return parsed;
};
const parseMinPasswordLength = (rawValue?: string): number => {
const parsed = Number(rawValue);
if (!Number.isFinite(parsed) || parsed <= 0) {
return 7;
}
return Math.floor(parsed);
};
const parseSameSite = (rawValue?: string): AuthSameSite => {
if (!rawValue) return DEFAULT_COOKIE_SAMESITE;
const normalized = rawValue.trim().toLowerCase();
@@ -73,50 +80,83 @@ const resolveAuthSecret = (enabled: boolean, env: NodeJS.ProcessEnv): Buffer =>
};
export const buildAuthConfig = (env: NodeJS.ProcessEnv = process.env): AuthConfig => {
const username = (env.AUTH_USERNAME || "").trim();
const password = env.AUTH_PASSWORD || "";
const enabled = username.length > 0 && password.length > 0;
const sessionTtlHours = parseSessionTtlHours(env.AUTH_SESSION_TTL_HOURS);
const cookieName = (env.AUTH_COOKIE_NAME || DEFAULT_COOKIE_NAME).trim();
const cookieSameSite = parseSameSite(env.AUTH_COOKIE_SAMESITE);
const minPasswordLength = parseMinPasswordLength(env.AUTH_MIN_PASSWORD_LENGTH);
return {
enabled,
username,
password,
enabled: true,
sessionTtlMs: sessionTtlHours * 60 * 60 * 1000,
cookieName: cookieName.length > 0 ? cookieName : DEFAULT_COOKIE_NAME,
cookieSameSite,
secret: resolveAuthSecret(enabled, env),
secret: resolveAuthSecret(true, env),
minPasswordLength,
};
};
const signToken = (secret: Buffer, payloadB64: string): Buffer =>
crypto.createHmac("sha256", secret).update(payloadB64, "utf8").digest();
const safeCompare = (left: string, right: string): boolean => {
const leftHash = crypto.createHash("sha256").update(left, "utf8").digest();
const rightHash = crypto.createHash("sha256").update(right, "utf8").digest();
return crypto.timingSafeEqual(leftHash, rightHash);
const PASSWORD_SALT_BYTES = 16;
const PASSWORD_HASH_BYTES = 64;
const PASSWORD_SCRYPT_OPTIONS = {
N: 16384,
r: 8,
p: 1,
maxmem: 64 * 1024 * 1024,
};
export const verifyCredentials = (
config: AuthConfig,
inputUsername: string,
inputPassword: string
): boolean => {
if (!config.enabled) return false;
return safeCompare(config.username, inputUsername) && safeCompare(config.password, inputPassword);
export const hashPassword = (password: string): string => {
const salt = crypto.randomBytes(PASSWORD_SALT_BYTES);
const derived = crypto.scryptSync(password, salt, PASSWORD_HASH_BYTES, PASSWORD_SCRYPT_OPTIONS);
return `${salt.toString("hex")}:${derived.toString("hex")}`;
};
export const createAuthSessionToken = (config: AuthConfig, username: string): string => {
export const verifyPassword = (password: string, storedHash: string): boolean => {
const [saltHex, derivedHex] = storedHash.split(":");
if (!saltHex || !derivedHex) return false;
const salt = Buffer.from(saltHex, "hex");
const derived = crypto.scryptSync(password, salt, PASSWORD_HASH_BYTES, PASSWORD_SCRYPT_OPTIONS);
const expected = Buffer.from(derivedHex, "hex");
if (expected.length !== derived.length) return false;
return crypto.timingSafeEqual(expected, derived);
};
export const isPasswordValid = (config: AuthConfig, password: string): boolean => {
if (typeof password !== "string") return false;
return password.trim().length >= config.minPasswordLength;
};
export const isEmailValid = (value: string | null | undefined): boolean => {
if (!value) return false;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
};
export const isUsernameValid = (value: string | null | undefined): boolean => {
if (!value) return false;
return /^[a-zA-Z0-9._-]+$/.test(value.trim());
};
export const generateRandomPassword = (length: number = 32): string => {
const chars =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+[]{}|;:,.<>?";
const bytes = crypto.randomBytes(length);
let result = "";
for (let i = 0; i < length; i += 1) {
result += chars[bytes[i] % chars.length];
}
return result;
};
export const createAuthSessionToken = (config: AuthConfig, userId: string): string => {
if (!config.enabled) {
throw new Error("Authentication is not enabled.");
}
const issuedAt = Date.now();
const payload: AuthSession = {
username,
userId,
iat: issuedAt,
exp: issuedAt + config.sessionTtlMs,
};
@@ -151,7 +191,7 @@ export const validateAuthSessionToken = (
const payloadJson = base64UrlDecode(payloadB64).toString("utf8");
const payload = JSON.parse(payloadJson) as Partial<AuthSession>;
if (
typeof payload.username !== "string" ||
typeof payload.userId !== "string" ||
typeof payload.iat !== "number" ||
typeof payload.exp !== "number"
) {
@@ -190,6 +230,9 @@ export const getAuthSessionFromCookie = (
return validateAuthSessionToken(config, token);
};
export const buildAuthIdentifier = (user: { username?: string | null; email?: string | null }) =>
user.username || user.email || "";
export const buildAuthCookieOptions = (
secure: boolean,
sameSite: AuthSameSite,
+501 -50
View File
File diff suppressed because it is too large Load Diff
+59 -32
View File
@@ -1,17 +1,27 @@
import { defineConfig, devices } from "@playwright/test";
import path from "path";
import os from "os";
// Centralized test environment URLs
const FRONTEND_PORT = 5173;
const BACKEND_PORT = 8000;
const FRONTEND_URL = process.env.BASE_URL || `http://localhost:${FRONTEND_PORT}`;
const BACKEND_URL = process.env.API_URL || http://localhost:${BACKEND_PORT}`;
const BACKEND_URL = process.env.API_URL || `http://localhost:${BACKEND_PORT}`;
const API_URL = BACKEND_URL;
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin";
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin123";
const AUTH_SESSION_SECRET = process.env.AUTH_SESSION_SECRET || "e2e-auth-secret";
const E2E_DB_NAME = process.env.E2E_DB_NAME || `e2e-${Date.now()}.db`;
const DATABASE_URL = process.env.DATABASE_URL || `file:${path.join(os.tmpdir(), E2E_DB_NAME)}`;
process.env.AUTH_USERNAME = AUTH_USERNAME;
process.env.AUTH_PASSWORD = AUTH_PASSWORD;
process.env.AUTH_SESSION_SECRET = AUTH_SESSION_SECRET;
process.env.AUTH_EMAIL = process.env.AUTH_EMAIL || "admin@example.com";
process.env.AUTH_MIN_PASSWORD_LENGTH = process.env.AUTH_MIN_PASSWORD_LENGTH || "7";
process.env.E2E_DB_NAME = E2E_DB_NAME;
process.env.DATABASE_URL = DATABASE_URL;
process.env.VITE_API_URL = process.env.VITE_API_URL || "/api";
/**
* Playwright configuration for E2E browser testing
@@ -26,7 +36,7 @@ export default defineConfig({
testDir: "./tests",
// Run tests in parallel
fullyParallel: true,
fullyParallel: false,
// Fail the build on test.only() in CI
forbidOnly: !!process.env.CI,
@@ -35,7 +45,7 @@ export default defineConfig({
retries: process.env.CI ? 2 : 0,
// Limit parallel workers in CI
workers: process.env.CI ? 1 : undefined,
workers: process.env.CI ? 1 : 1,
// Reporter configuration
reporter: [
@@ -65,6 +75,9 @@ export default defineConfig({
// Base URL for page.goto()
baseURL: FRONTEND_URL,
// Load shared auth state
storageState: path.resolve(__dirname, "tests/.auth/storageState.json"),
// Collect trace on first retry
trace: "on-first-retry",
@@ -90,32 +103,46 @@ export default defineConfig({
],
// Run local dev servers before tests (skip if NO_SERVER or CI)
webServer: (process.env.CI || process.env.NO_SERVER === "true") ? undefined : [
{
command: "cd ../backend && npm run dev",
url: `${BACKEND_URL}/health`,
reuseExistingServer: true,
timeout: 120000,
stdout: "pipe",
stderr: "pipe",
env: {
// Prisma resolves relative SQLite paths from the schema directory (backend/prisma).
// Using `file:./dev.db` avoids accidentally creating `prisma/prisma/dev.db`.
DATABASE_URL: "file:./dev.db",
FRONTEND_URL,
CSRF_MAX_REQUESTS: "1000",
AUTH_USERNAME,
AUTH_PASSWORD,
AUTH_SESSION_SECRET,
},
},
{
command: "cd ../frontend && npm run dev -- --host",
url: FRONTEND_URL,
reuseExistingServer: true,
timeout: 120000,
stdout: "pipe",
stderr: "pipe",
},
],
webServer: (process.env.CI || process.env.NO_SERVER === "true")
? undefined
: [
{
command: "cd ../backend && npx prisma db push && npx ts-node src/index.ts",
url: `${BACKEND_URL}/health`,
reuseExistingServer: true,
timeout: 120000,
stdout: "pipe",
stderr: "pipe",
env: {
// Prisma resolves relative SQLite paths from the schema directory (backend/prisma).
DATABASE_URL,
FRONTEND_URL,
CSRF_MAX_REQUESTS: "10000",
AUTH_USERNAME,
AUTH_PASSWORD,
AUTH_MIN_PASSWORD_LENGTH: "7",
AUTH_SESSION_SECRET,
AUTH_SESSION_TTL_HOURS: "4",
RATE_LIMIT_MAX_REQUESTS: "20000",
NODE_ENV: "e2e",
TS_NODE_TRANSPILE_ONLY: "1",
},
},
{
command: "cd ../frontend && npm run dev -- --host",
url: FRONTEND_URL,
reuseExistingServer: true,
timeout: 120000,
stdout: "pipe",
stderr: "pipe",
env: {
VITE_API_URL: "/api",
API_URL,
},
},
],
globalSetup: require.resolve("./tests/global-setup"),
globalTeardown: require.resolve("./tests/global-teardown"),
});
+9 -4
View File
@@ -1,16 +1,19 @@
import { test, expect } from "./fixtures";
test.describe("Authentication", () => {
test.use({ skipAuth: true });
test("should require login and allow logout", async ({ page }) => {
const username = process.env.AUTH_USERNAME || "admin";
const password = process.env.AUTH_PASSWORD || "admin";
const password = process.env.AUTH_PASSWORD || "admin123";
await page.context().clearCookies();
await page.goto("/");
await expect(page.getByText("Sign in to access your drawings")).toBeVisible();
const signInPrompt = page.getByText("Sign in to access your drawings");
await expect(signInPrompt).toBeVisible();
await page.getByLabel("Username").fill(username);
await page.getByLabel("Username or Email").fill(username);
await page.getByLabel("Password").fill(password);
await page.getByRole("button", { name: "Sign in" }).click();
@@ -22,6 +25,8 @@ test.describe("Authentication", () => {
await expect(logoutButton).toBeVisible();
await logoutButton.click();
await expect(page.getByText("Sign in to access your drawings")).toBeVisible();
await expect(signInPrompt).toBeVisible();
await page.context().clearCookies();
});
});
+4 -3
View File
@@ -70,20 +70,21 @@ test.describe("Dashboard Workflows", () => {
await page.waitForLoadState("networkidle");
await applyDashboardSearch(page, drawingName);
const cardLocator = await ensureCardVisible(page, createdDrawing.id);
let cardLocator = await ensureCardVisible(page, createdDrawing.id);
await ensureCardSelected(page, createdDrawing.id);
await page.getByTitle("Move to Trash").click();
await expect(cardLocator).toHaveCount(0);
await page.getByRole("button", { name: /^Trash$/ }).click();
const trashCard = await ensureCardVisible(page, createdDrawing.id);
await applyDashboardSearch(page, drawingName);
cardLocator = await ensureCardVisible(page, createdDrawing.id);
await ensureCardSelected(page, createdDrawing.id);
await page.getByTitle("Delete Permanently").click();
await page.getByRole("button", { name: /Delete \d+ Drawings/ }).click();
await expect(trashCard).toHaveCount(0);
await expect(cardLocator).toHaveCount(0);
const response = await request.get(`${API_URL}/drawings/${createdDrawing.id}`);
expect(response.status()).toBe(404);
+2
View File
@@ -52,6 +52,8 @@ test.describe("Drag and Drop - Collections", () => {
await page.goto("/");
await page.waitForLoadState("networkidle");
await page.getByPlaceholder("Search drawings...").fill(drawing.name);
await page.waitForTimeout(500);
// Find the drawing card
const card = page.locator(`#drawing-card-${drawing.id}`);
+2 -1
View File
@@ -3,6 +3,7 @@ import {
API_URL,
createDrawing,
deleteDrawing,
ensureAuthenticated,
getCsrfHeaders,
listDrawings,
deleteCollection,
@@ -381,9 +382,9 @@ test.describe("Database Import Verification", () => {
test("should verify SQLite import endpoint exists", async ({ request }) => {
// Test that the verification endpoint responds
// We don't actually import a database as that would affect the test environment
await ensureAuthenticated(request);
const response = await request.post(`${API_URL}/import/sqlite/verify`, {
headers: await getCsrfHeaders(request),
// Send empty form data to test endpoint exists
multipart: {
db: {
name: "test.sqlite",
+57 -18
View File
@@ -1,24 +1,63 @@
import { test as base, expect } from "@playwright/test";
import { ensurePageAuthenticated } from "./helpers/auth";
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin";
type Fixtures = {
skipAuth: boolean;
};
export const test = base;
test.beforeEach(async ({ page }) => {
// Navigate to root to check if we need to login
await page.goto("/");
await page.waitForLoadState("domcontentloaded");
// If we see the login page, perform login
const loginText = page.getByText("Sign in to access your drawings");
if (await loginText.isVisible({ timeout: 2000 }).catch(() => false)) {
await page.getByLabel("Username").fill(AUTH_USERNAME);
await page.getByLabel("Password").fill(AUTH_PASSWORD);
await page.getByRole("button", { name: "Sign in" }).click();
// Wait for dashboard to load
await page.getByPlaceholder("Search drawings...").waitFor({ state: "visible", timeout: 15000 });
}
export const test = base.extend<Fixtures>({
skipAuth: [false, { option: true }],
});
test.beforeEach(async ({ page, skipAuth }) => {
if (skipAuth) {
return;
}
await ensurePageAuthenticated(page);
let authCheckInFlight: Promise<void> | null = null;
const maybeReauthenticate = async () => {
if (authCheckInFlight) {
return authCheckInFlight;
}
authCheckInFlight = (async () => {
const loginPrompt = page.getByText("Sign in to access your drawings");
if (await loginPrompt.isVisible({ timeout: 3000 }).catch(() => false)) {
await ensurePageAuthenticated(page, { skipNavigation: true });
}
})().finally(() => {
authCheckInFlight = null;
});
return authCheckInFlight;
};
page.on("framenavigated", async (frame) => {
if (frame !== page.mainFrame()) {
return;
}
await maybeReauthenticate();
});
page.on("response", async (response) => {
if (!response.url().includes("/auth/status")) {
return;
}
try {
const status = (await response.json()) as { authenticated?: boolean };
if (status && status.authenticated === false) {
await maybeReauthenticate();
}
} catch {
// Ignore parse errors to avoid flakiness.
}
});
});
export { expect };
+50
View File
@@ -0,0 +1,50 @@
import { promises as fs } from "fs";
import path from "path";
import { request } from "@playwright/test";
import { ensureApiAuthenticated } from "./helpers/auth";
const AUTH_STATE_PATH = path.resolve(__dirname, ".auth/storageState.json");
const waitForServer = async (baseURL: string) => {
const apiRequest = await request.newContext({ baseURL });
const timeoutMs = 60000;
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const response = await apiRequest.get("/health");
if (response.ok()) {
await apiRequest.dispose();
return;
}
} catch {
// Ignore connection errors while server boots.
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
await apiRequest.dispose();
throw new Error(`Backend did not become ready within ${timeoutMs}ms`);
};
const globalSetup = async () => {
const baseURL = process.env.API_URL || "http://localhost:8000";
await waitForServer(baseURL);
const apiRequest = await request.newContext({
baseURL,
extraHTTPHeaders: {
Connection: "close",
},
});
try {
await ensureApiAuthenticated(apiRequest);
await fs.mkdir(path.dirname(AUTH_STATE_PATH), { recursive: true });
await apiRequest.storageState({ path: AUTH_STATE_PATH });
} finally {
await apiRequest.dispose();
}
};
export default globalSetup;
+14
View File
@@ -0,0 +1,14 @@
import { promises as fs } from "fs";
import path from "path";
const AUTH_STATE_PATH = path.resolve(__dirname, ".auth/storageState.json");
const globalTeardown = async () => {
try {
await fs.unlink(AUTH_STATE_PATH);
} catch {
// Ignore missing auth state file.
}
};
export default globalTeardown;
+199 -24
View File
@@ -1,4 +1,6 @@
import { APIRequestContext, expect } from "@playwright/test";
import { APIRequestContext } from "@playwright/test";
import { expect } from "@playwright/test";
// Default ports match the Playwright config
const DEFAULT_BACKEND_PORT = 8000;
@@ -6,7 +8,7 @@ const DEFAULT_BACKEND_PORT = 8000;
export const API_URL = process.env.API_URL || `http://localhost:${DEFAULT_BACKEND_PORT}`;
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin";
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin123";
// Track authenticated API contexts
const authenticatedContexts = new WeakSet<APIRequestContext>();
@@ -20,10 +22,17 @@ export async function ensureAuthenticated(request: APIRequestContext): Promise<v
// Check current auth status
const statusResp = await request.get(`${API_URL}/auth/status`);
if (!statusResp.ok()) {
if (statusResp.status() === 401) {
authenticatedContexts.delete(request);
}
throw new Error(`Failed to check auth status: ${statusResp.status()}`);
}
const status = (await statusResp.json()) as { enabled: boolean; authenticated: boolean };
const status = (await statusResp.json()) as {
enabled: boolean;
authenticated: boolean;
bootstrapRequired?: boolean;
};
if (!status.enabled) {
// Auth is disabled, mark as "authenticated"
@@ -36,19 +45,53 @@ export async function ensureAuthenticated(request: APIRequestContext): Promise<v
return;
}
// Need to login
const csrfHeaders = await getCsrfHeaders(request);
const loginResp = await request.post(`${API_URL}/auth/login`, {
headers: {
if (status.bootstrapRequired) {
const bootstrapHeaders = await withCsrfHeaders(request, {
"Content-Type": "application/json",
...csrfHeaders,
},
});
const bootstrapResp = await request.post(`${API_URL}/auth/bootstrap`, {
headers: bootstrapHeaders,
data: {
username: AUTH_USERNAME,
password: AUTH_PASSWORD,
},
});
if (!bootstrapResp.ok()) {
const text = await bootstrapResp.text();
throw new Error(`API bootstrap failed: ${bootstrapResp.status()} ${text}`);
}
authenticatedContexts.add(request);
return;
}
// Need to login
let loginHeaders = await withCsrfHeaders(request, {
"Content-Type": "application/json",
});
let loginResp = await request.post(`${API_URL}/auth/login`, {
headers: loginHeaders,
data: {
username: AUTH_USERNAME,
password: AUTH_PASSWORD,
},
});
if (!loginResp.ok() && loginResp.status() === 403) {
await refreshCsrfInfo(request);
loginHeaders = await withCsrfHeaders(request, {
"Content-Type": "application/json",
});
loginResp = await request.post(`${API_URL}/auth/login`, {
headers: loginHeaders,
data: {
username: AUTH_USERNAME,
password: AUTH_PASSWORD,
},
});
}
if (!loginResp.ok()) {
const text = await loginResp.text();
throw new Error(`API authentication failed: ${loginResp.status()} ${text}`);
@@ -67,12 +110,18 @@ type CsrfInfo = {
headerName: string;
};
const buildBaseHeaders = (_request: APIRequestContext): Record<string, string> => ({
origin: process.env.BASE_URL || "http://localhost:5173",
});
// Cache CSRF tokens per Playwright request context so parallel tests don't race.
const csrfInfoByRequest = new WeakMap<APIRequestContext, CsrfInfo>();
const csrfFetchByRequest = new WeakMap<APIRequestContext, Promise<CsrfInfo>>();
const fetchCsrfInfo = async (request: APIRequestContext): Promise<CsrfInfo> => {
const response = await request.get(`${API_URL}/csrf-token`);
const response = await request.get(`${API_URL}/csrf-token`, {
headers: buildBaseHeaders(request),
});
if (!response.ok()) {
const text = await response.text();
throw new Error(
@@ -127,6 +176,11 @@ const refreshCsrfInfo = async (request: APIRequestContext): Promise<CsrfInfo> =>
return promise;
};
export const refreshCsrfToken = async (request: APIRequestContext): Promise<void> => {
authenticatedContexts.delete(request);
await refreshCsrfInfo(request);
};
export async function getCsrfHeaders(
request: APIRequestContext
): Promise<Record<string, string>> {
@@ -138,6 +192,7 @@ const withCsrfHeaders = async (
request: APIRequestContext,
headers: Record<string, string> = {}
): Promise<Record<string, string>> => ({
...buildBaseHeaders(request),
...headers,
...(await getCsrfHeaders(request)),
});
@@ -199,6 +254,26 @@ export async function createDrawing(
data: payload,
});
if (!response.ok() && response.status() === 401) {
authenticatedContexts.delete(request);
await ensureAuthenticated(request);
const retryHeaders = await withCsrfHeaders(request, {
"Content-Type": "application/json",
});
response = await request.post(`${API_URL}/drawings`, {
headers: retryHeaders,
data: payload,
});
}
if (!response.ok() && response.status() === 503) {
await new Promise((resolve) => setTimeout(resolve, 500));
response = await request.post(`${API_URL}/drawings`, {
headers,
data: payload,
});
}
// Retry once with a fresh token in case it expired or the cache was primed under
// a different clientId (rare, but can happen under parallelism / CI proxies).
if (!response.ok() && response.status() === 403) {
@@ -216,7 +291,14 @@ export async function createDrawing(
const text = await response.text();
throw new Error(`Failed to create drawing: ${response.status()} ${text}`);
}
return (await response.json()) as DrawingRecord;
const created = (await response.json()) as DrawingRecord;
try {
await request.get(`${API_URL}/drawings/${created.id}`);
} catch {
// Ignore warm-up failures to keep tests resilient.
}
return created;
}
export async function getDrawing(
@@ -224,7 +306,20 @@ export async function getDrawing(
id: string
): Promise<DrawingRecord> {
await ensureAuthenticated(request);
const response = await request.get(`${API_URL}/drawings/${id}`);
let response = await request.get(`${API_URL}/drawings/${id}`);
if (!response.ok() && response.status() === 401) {
authenticatedContexts.delete(request);
await ensureAuthenticated(request);
response = await request.get(`${API_URL}/drawings/${id}`);
}
if (!response.ok() && response.status() === 503) {
await new Promise((resolve) => setTimeout(resolve, 500));
response = await request.get(`${API_URL}/drawings/${id}`);
}
expect(response.ok()).toBe(true);
return (await response.json()) as DrawingRecord;
}
@@ -234,15 +329,25 @@ export async function deleteDrawing(
id: string
): Promise<void> {
await ensureAuthenticated(request);
const headers = await withCsrfHeaders(request);
let headers = await withCsrfHeaders(request);
let response = await request.delete(`${API_URL}/drawings/${id}`, { headers });
if (!response.ok() && response.status() === 401) {
authenticatedContexts.delete(request);
await ensureAuthenticated(request);
headers = await withCsrfHeaders(request);
response = await request.delete(`${API_URL}/drawings/${id}`, { headers });
}
if (!response.ok() && response.status() === 503) {
await new Promise((resolve) => setTimeout(resolve, 500));
response = await request.delete(`${API_URL}/drawings/${id}`, { headers });
}
if (!response.ok() && response.status() === 403) {
await refreshCsrfInfo(request);
const retryHeaders = await withCsrfHeaders(request);
response = await request.delete(`${API_URL}/drawings/${id}`, {
headers: retryHeaders,
});
headers = await withCsrfHeaders(request);
response = await request.delete(`${API_URL}/drawings/${id}`, { headers });
}
if (!response.ok()) {
@@ -252,6 +357,12 @@ export async function deleteDrawing(
throw new Error(`Failed to delete drawing ${id}: ${response.status()} ${text}`);
}
}
try {
await request.get(`${API_URL}/drawings/${id}`);
} catch {
// Ignore cache warm-up failures.
}
}
export async function listDrawings(
@@ -270,9 +381,25 @@ export async function listDrawings(
if (options.includeData) params.set("includeData", "true");
const query = params.toString();
const response = await request.get(
let response = await request.get(
`${API_URL}/drawings${query ? `?${query}` : ""}`
);
if (!response.ok() && response.status() === 401) {
authenticatedContexts.delete(request);
await ensureAuthenticated(request);
response = await request.get(
`${API_URL}/drawings${query ? `?${query}` : ""}`
);
}
if (!response.ok() && response.status() === 503) {
await new Promise((resolve) => setTimeout(resolve, 500));
response = await request.get(
`${API_URL}/drawings${query ? `?${query}` : ""}`
);
}
expect(response.ok()).toBe(true);
return (await response.json()) as DrawingRecord[];
}
@@ -289,6 +416,26 @@ export async function createCollection(
data: { name },
});
if (!response.ok() && response.status() === 401) {
authenticatedContexts.delete(request);
await ensureAuthenticated(request);
const retryHeaders = await withCsrfHeaders(request, {
"Content-Type": "application/json",
});
response = await request.post(`${API_URL}/collections`, {
headers: retryHeaders,
data: { name },
});
}
if (!response.ok() && response.status() === 503) {
await new Promise((resolve) => setTimeout(resolve, 500));
response = await request.post(`${API_URL}/collections`, {
headers,
data: { name },
});
}
if (!response.ok() && response.status() === 403) {
await refreshCsrfInfo(request);
const retryHeaders = await withCsrfHeaders(request, {
@@ -308,7 +455,19 @@ export async function listCollections(
request: APIRequestContext
): Promise<CollectionRecord[]> {
await ensureAuthenticated(request);
const response = await request.get(`${API_URL}/collections`);
let response = await request.get(`${API_URL}/collections`);
if (!response.ok() && response.status() === 401) {
authenticatedContexts.delete(request);
await ensureAuthenticated(request);
response = await request.get(`${API_URL}/collections`);
}
if (!response.ok() && response.status() === 503) {
await new Promise((resolve) => setTimeout(resolve, 500));
response = await request.get(`${API_URL}/collections`);
}
expect(response.ok()).toBe(true);
return (await response.json()) as CollectionRecord[];
}
@@ -318,15 +477,25 @@ export async function deleteCollection(
id: string
): Promise<void> {
await ensureAuthenticated(request);
const headers = await withCsrfHeaders(request);
let headers = await withCsrfHeaders(request);
let response = await request.delete(`${API_URL}/collections/${id}`, { headers });
if (!response.ok() && response.status() === 401) {
authenticatedContexts.delete(request);
await ensureAuthenticated(request);
headers = await withCsrfHeaders(request);
response = await request.delete(`${API_URL}/collections/${id}`, { headers });
}
if (!response.ok() && response.status() === 503) {
await new Promise((resolve) => setTimeout(resolve, 500));
response = await request.delete(`${API_URL}/collections/${id}`, { headers });
}
if (!response.ok() && response.status() === 403) {
await refreshCsrfInfo(request);
const retryHeaders = await withCsrfHeaders(request);
response = await request.delete(`${API_URL}/collections/${id}`, {
headers: retryHeaders,
});
headers = await withCsrfHeaders(request);
response = await request.delete(`${API_URL}/collections/${id}`, { headers });
}
if (!response.ok()) {
@@ -335,4 +504,10 @@ export async function deleteCollection(
throw new Error(`Failed to delete collection ${id}: ${response.status()} ${text}`);
}
}
try {
await request.get(`${API_URL}/collections`);
} catch {
// Ignore cache warm-up failures.
}
}
+146 -24
View File
@@ -1,34 +1,44 @@
import { APIRequestContext, Page } from "@playwright/test";
import { API_URL, getCsrfHeaders } from "./api";
import { API_URL, getCsrfHeaders, refreshCsrfToken } from "./api";
const BASE_URL = process.env.API_URL || API_URL;
type AuthStatus = {
enabled: boolean;
authenticated: boolean;
bootstrapRequired?: boolean;
};
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin";
const authStatusCache = new WeakMap<APIRequestContext, AuthStatus>();
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin123";
const fetchAuthStatus = async (request: APIRequestContext): Promise<AuthStatus> => {
const cached = authStatusCache.get(request);
// Only use cache if we're already authenticated
if (cached?.authenticated) return cached;
const maxAttempts = 3;
const response = await request.get(`${API_URL}/auth/status`);
if (!response.ok()) {
const text = await response.text();
throw new Error(`Failed to fetch auth status: ${response.status()} ${text}`);
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
const response = await request.get(`${BASE_URL}/auth/status`);
if (response.ok()) {
return (await response.json()) as AuthStatus;
}
const text = await response.text();
if (response.status() === 429 && attempt < maxAttempts - 1) {
await new Promise((resolve) => setTimeout(resolve, 1000 + attempt * 500));
continue;
}
throw new Error(`Failed to fetch auth status: ${response.status()} ${text}`);
} catch (error) {
if (attempt < maxAttempts - 1) {
await new Promise((resolve) => setTimeout(resolve, 500 + attempt * 250));
continue;
}
throw error;
}
}
const data = (await response.json()) as AuthStatus;
authStatusCache.set(request, data);
return data;
};
const setAuthStatus = (request: APIRequestContext, status: AuthStatus) => {
authStatusCache.set(request, status);
throw new Error("Failed to fetch auth status");
};
export const ensureApiAuthenticated = async (request: APIRequestContext) => {
@@ -37,11 +47,44 @@ export const ensureApiAuthenticated = async (request: APIRequestContext) => {
return;
}
const headers = await getCsrfHeaders(request);
const response = await request.post(`${API_URL}/auth/login`, {
if (status.bootstrapRequired) {
let response = await request.post(`${BASE_URL}/auth/bootstrap`, {
headers: {
"Content-Type": "application/json",
...(await getCsrfHeaders(request)),
},
data: {
username: AUTH_USERNAME,
password: AUTH_PASSWORD,
},
});
if (!response.ok() && response.status() === 403) {
await refreshCsrfToken(request);
response = await request.post(`${BASE_URL}/auth/bootstrap`, {
headers: {
"Content-Type": "application/json",
...(await getCsrfHeaders(request)),
},
data: {
username: AUTH_USERNAME,
password: AUTH_PASSWORD,
},
});
}
if (!response.ok()) {
const text = await response.text();
throw new Error(`Failed to bootstrap test session: ${response.status()} ${text}`);
}
return;
}
let response = await request.post(`${BASE_URL}/auth/login`, {
headers: {
"Content-Type": "application/json",
...headers,
...(await getCsrfHeaders(request)),
},
data: {
username: AUTH_USERNAME,
@@ -49,14 +92,93 @@ export const ensureApiAuthenticated = async (request: APIRequestContext) => {
},
});
if (!response.ok() && response.status() === 403) {
await refreshCsrfToken(request);
const freshHeaders = {
"Content-Type": "application/json",
...(await getCsrfHeaders(request)),
};
response = await request.post(`${BASE_URL}/auth/login`, {
headers: freshHeaders,
data: {
username: AUTH_USERNAME,
password: AUTH_PASSWORD,
},
});
}
if (!response.ok()) {
const text = await response.text();
throw new Error(`Failed to authenticate test session: ${response.status()} ${text}`);
}
setAuthStatus(request, { enabled: true, authenticated: true });
};
export const ensurePageAuthenticated = async (page: Page) => {
type EnsureAuthOptions = {
skipNavigation?: boolean;
};
export const ensurePageAuthenticated = async (
page: Page,
{ skipNavigation = false }: EnsureAuthOptions = {}
) => {
await ensureApiAuthenticated(page.request);
const storageState = await page.request.storageState();
if (storageState.cookies.length > 0) {
await page.context().addCookies(
storageState.cookies.filter((cookie) => cookie.name && cookie.value)
);
}
if (!skipNavigation) {
await page.goto("/", { waitUntil: "domcontentloaded" });
}
const dashboardReady = page.getByPlaceholder("Search drawings...");
const identifierField = page.getByLabel("Username or Email");
const passwordField = page.getByLabel("Password");
if (skipNavigation) {
if (await identifierField.isVisible({ timeout: 1000 }).catch(() => false)) {
await identifierField.fill(AUTH_USERNAME);
await passwordField.fill(AUTH_PASSWORD);
const confirmPasswordField = page.getByLabel("Confirm Password");
if (await confirmPasswordField.isVisible().catch(() => false)) {
await confirmPasswordField.fill(AUTH_PASSWORD);
}
await page
.getByRole("button", { name: /sign in|create admin|create account/i })
.click();
await dashboardReady.waitFor({ state: "visible", timeout: 30000 });
}
return;
}
await Promise.race([
dashboardReady.waitFor({ state: "visible", timeout: 15000 }),
identifierField.waitFor({ state: "visible", timeout: 15000 }),
]);
if (await dashboardReady.isVisible().catch(() => false)) {
return;
}
if (await identifierField.isVisible().catch(() => false)) {
await identifierField.fill(AUTH_USERNAME);
await passwordField.fill(AUTH_PASSWORD);
const confirmPasswordField = page.getByLabel("Confirm Password");
if (await confirmPasswordField.isVisible().catch(() => false)) {
await confirmPasswordField.fill(AUTH_PASSWORD);
}
await page
.getByRole("button", { name: /sign in|create admin|create account/i })
.click();
await dashboardReady.waitFor({ state: "visible", timeout: 30000 });
return;
}
await dashboardReady.waitFor({ state: "visible", timeout: 15000 });
};
+5
View File
@@ -5,6 +5,7 @@ import {
API_URL,
createDrawing,
deleteDrawing,
ensureAuthenticated,
getCsrfHeaders,
getDrawing,
} from "./helpers/api";
@@ -199,6 +200,7 @@ test.describe("Security - Malicious Content Blocking", () => {
},
};
await ensureAuthenticated(request);
const response = await request.post(`${API_URL}/drawings`, {
headers: {
"Content-Type": "application/json",
@@ -225,6 +227,7 @@ test.describe("Security - Malicious Content Blocking", () => {
expect(savedFiles["malicious-image"].dataURL).not.toContain("javascript:");
// Cleanup
await ensureAuthenticated(request);
await request.delete(`${API_URL}/drawings/${drawing.id}`, {
headers: await getCsrfHeaders(request),
});
@@ -240,6 +243,7 @@ test.describe("Security - Malicious Content Blocking", () => {
},
};
await ensureAuthenticated(request);
const response = await request.post(`${API_URL}/drawings`, {
headers: {
"Content-Type": "application/json",
@@ -266,6 +270,7 @@ test.describe("Security - Malicious Content Blocking", () => {
expect(savedFiles["malicious-image"].dataURL).not.toContain("<script>");
// Cleanup
await ensureAuthenticated(request);
await request.delete(`${API_URL}/drawings/${drawing.id}`, {
headers: await getCsrfHeaders(request),
});
+45
View File
@@ -0,0 +1,45 @@
import { test, expect } from "./fixtures";
import { API_URL } from "./helpers/api";
const registerUser = async (request: any, payload: any) => {
const csrfResponse = await request.get(`${API_URL}/csrf-token`);
if (!csrfResponse.ok()) {
throw new Error(`Failed to get CSRF token: ${csrfResponse.status()}`);
}
const data = (await csrfResponse.json()) as { token: string; header?: string };
const headerName = data.header || "x-csrf-token";
return request.post(`${API_URL}/auth/register`, {
headers: {
"Content-Type": "application/json",
[headerName]: data.token,
},
data: payload,
});
};
test.describe("Registration", () => {
test("allows admin to enable registration and create user", async ({ page, request }) => {
await page.goto("/settings");
await page.getByRole("button", { name: /Enable registration/i }).click();
await expect(page.getByText(/Registration is enabled/i)).toBeVisible();
const registerResponse = await registerUser(request, {
username: `newuser-${Date.now()}`,
password: "password123",
});
expect(registerResponse.status()).toBe(201);
});
test("blocks registration when disabled", async ({ page, request }) => {
await page.goto("/settings");
await page.getByRole("button", { name: /Disable registration/i }).click();
await expect(page.getByText(/Registration is disabled/i)).toBeVisible();
const registerResponse = await registerUser(request, {
username: "blocked",
password: "password123",
});
expect(registerResponse.status()).toBe(403);
});
});
+40 -1
View File
@@ -11,7 +11,9 @@ export const api = axios.create({
export type AuthStatus = {
enabled: boolean;
authenticated: boolean;
user: { username: string } | null;
registrationEnabled: boolean;
bootstrapRequired: boolean;
user: { id: string; username: string | null; email: string | null; role: "ADMIN" | "USER" } | null;
};
let unauthorizedHandler: (() => void) | null = null;
@@ -133,6 +135,43 @@ export const logout = async () => {
return response.data;
};
export const register = async (payload: {
username?: string;
email?: string;
password: string;
}) => {
const response = await api.post<{ user: AuthStatus["user"] }>("/auth/register", payload);
return response.data;
};
export const bootstrapAdmin = async (payload: {
username?: string;
email?: string;
password: string;
}) => {
const response = await api.post<{ user: AuthStatus["user"]; authenticated: boolean }>(
"/auth/bootstrap",
payload
);
return response.data;
};
export const setRegistrationEnabled = async (enabled: boolean) => {
const response = await api.post<{ registrationEnabled: boolean }>(
"/auth/registration/toggle",
{ enabled }
);
return response.data;
};
export const updateUserRole = async (identifier: string, role: "ADMIN" | "USER") => {
const response = await api.post<{ user: AuthStatus["user"] }>("/auth/admins", {
identifier,
role,
});
return response.data;
};
const coerceTimestamp = (value: string | number | Date): number => {
if (typeof value === "number") return value;
if (value instanceof Date) return value.getTime();
+57 -2
View File
@@ -12,7 +12,9 @@ import * as api from "../api";
type AuthState = {
enabled: boolean;
authenticated: boolean;
user: { username: string } | null;
registrationEnabled: boolean;
bootstrapRequired: boolean;
user: { id: string; username: string | null; email: string | null; role: "ADMIN" | "USER" } | null;
loading: boolean;
statusError: string | null;
};
@@ -21,6 +23,10 @@ type AuthContextValue = {
state: AuthState;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
register: (payload: { username?: string; email?: string; password: string }) => Promise<void>;
bootstrapAdmin: (payload: { username?: string; email?: string; password: string }) => Promise<void>;
setRegistrationEnabled: (enabled: boolean) => Promise<void>;
updateUserRole: (identifier: string, role: "ADMIN" | "USER") => Promise<void>;
refreshStatus: () => Promise<void>;
};
@@ -30,6 +36,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const [state, setState] = useState<AuthState>({
enabled: false,
authenticated: false,
registrationEnabled: false,
bootstrapRequired: false,
user: null,
loading: true,
statusError: null,
@@ -45,6 +53,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setState({
enabled: status.enabled,
authenticated: status.authenticated,
registrationEnabled: status.registrationEnabled,
bootstrapRequired: status.bootstrapRequired,
user: status.user,
loading: false,
statusError: null,
@@ -89,14 +99,59 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
await refreshStatus();
}, [refreshStatus]);
const register = useCallback(
async (payload: { username?: string; email?: string; password: string }) => {
await api.register(payload);
await refreshStatus();
},
[refreshStatus]
);
const bootstrapAdmin = useCallback(
async (payload: { username?: string; email?: string; password: string }) => {
await api.bootstrapAdmin(payload);
await refreshStatus();
},
[refreshStatus]
);
const setRegistrationEnabled = useCallback(
async (enabled: boolean) => {
await api.setRegistrationEnabled(enabled);
await refreshStatus();
},
[refreshStatus]
);
const updateUserRole = useCallback(
async (identifier: string, role: "ADMIN" | "USER") => {
await api.updateUserRole(identifier, role);
await refreshStatus();
},
[refreshStatus]
);
const value = useMemo<AuthContextValue>(
() => ({
state,
login,
logout,
register,
bootstrapAdmin,
setRegistrationEnabled,
updateUserRole,
refreshStatus,
}),
[state, login, logout, refreshStatus]
[
state,
login,
logout,
register,
bootstrapAdmin,
setRegistrationEnabled,
updateUserRole,
refreshStatus,
]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+89 -12
View File
@@ -4,22 +4,54 @@ import { Logo } from "../components/Logo";
import { useAuth } from "../context/AuthContext";
export const Login: React.FC = () => {
const { login } = useAuth();
const [username, setUsername] = useState("");
const { state, login, register, bootstrapAdmin } = useAuth();
const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showRegister, setShowRegister] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const isBootstrap = state.bootstrapRequired;
const canRegister = state.registrationEnabled;
const parseIdentifier = () => {
const trimmed = identifier.trim();
if (!trimmed) return { username: "", email: "" };
if (trimmed.includes("@")) {
return { email: trimmed, username: "" };
}
return { username: trimmed, email: "" };
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setIsSubmitting(true);
try {
await login(username.trim(), password);
if (showRegister || isBootstrap) {
if (password !== confirmPassword) {
setError("Passwords do not match.");
return;
}
const { username, email } = parseIdentifier();
if (!username && !email) {
setError("Enter a username or email address.");
return;
}
if (isBootstrap) {
await bootstrapAdmin({ username: username || undefined, email: email || undefined, password });
} else {
await register({ username: username || undefined, email: email || undefined, password });
await login(identifier.trim(), password);
}
} else {
await login(identifier.trim(), password);
}
} catch (err) {
console.error("Login failed:", err);
setError("Invalid username or password.");
console.error("Auth failed:", err);
setError("Unable to complete authentication.");
} finally {
setIsSubmitting(false);
}
@@ -34,20 +66,24 @@ export const Login: React.FC = () => {
ExcaliDash
</h1>
<p className="mt-2 text-sm text-slate-500 dark:text-neutral-400 font-medium">
Sign in to access your drawings
{isBootstrap
? "Create the initial admin account"
: showRegister
? "Create a new account"
: "Sign in to access your drawings"}
</p>
</div>
<form onSubmit={handleSubmit} className="mt-8 space-y-5">
<label className="block text-sm font-semibold text-slate-700 dark:text-neutral-200">
Username
Username or Email
<input
type="text"
name="username"
name="identifier"
autoComplete="username"
required
value={username}
onChange={(event) => setUsername(event.target.value)}
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
className="mt-2 w-full rounded-xl border-2 border-black dark:border-neutral-700 bg-white dark:bg-neutral-800 px-4 py-3 text-base text-slate-900 dark:text-white shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] dark:shadow-[3px_3px_0px_0px_rgba(255,255,255,0.2)] focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</label>
@@ -57,7 +93,7 @@ export const Login: React.FC = () => {
<input
type="password"
name="password"
autoComplete="current-password"
autoComplete={showRegister || isBootstrap ? "new-password" : "current-password"}
required
value={password}
onChange={(event) => setPassword(event.target.value)}
@@ -65,6 +101,21 @@ export const Login: React.FC = () => {
/>
</label>
{(showRegister || isBootstrap) && (
<label className="block text-sm font-semibold text-slate-700 dark:text-neutral-200">
Confirm Password
<input
type="password"
name="confirm-password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
className="mt-2 w-full rounded-xl border-2 border-black dark:border-neutral-700 bg-white dark:bg-neutral-800 px-4 py-3 text-base text-slate-900 dark:text-white shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] dark:shadow-[3px_3px_0px_0px_rgba(255,255,255,0.2)] focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</label>
)}
{error && (
<p className="text-sm text-rose-600 dark:text-rose-400 font-semibold">
{error}
@@ -76,8 +127,34 @@ export const Login: React.FC = () => {
disabled={isSubmitting}
className="w-full flex items-center justify-center gap-2 rounded-xl border-2 border-black dark:border-neutral-700 bg-indigo-600 text-white px-4 py-3 text-base font-bold shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] dark:shadow-[3px_3px_0px_0px_rgba(255,255,255,0.2)] transition-all duration-200 hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-70"
>
{isSubmitting ? <Loader2 className="h-5 w-5 animate-spin" /> : "Sign in"}
{isSubmitting ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : isBootstrap ? (
"Create Admin"
) : showRegister ? (
"Create account"
) : (
"Sign in"
)}
</button>
{!isBootstrap && (
<button
type="button"
onClick={() => {
setError(null);
setShowRegister((prev) => !prev);
}}
disabled={!canRegister && !showRegister}
className="w-full text-sm font-semibold text-slate-600 dark:text-neutral-300 disabled:opacity-50"
>
{showRegister
? "Back to sign in"
: canRegister
? "Need an account? Register"
: "Registration is disabled"}
</button>
)}
</form>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More