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 ### Optional Authentication
ExcaliDash can enforce a single username/password to protect the dashboard and API. ExcaliDash supports multi-user authentication with role-based administration.
Set these backend environment variables to enable it: 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 ```bash
# Optional (defaults to "admin")
AUTH_USERNAME=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 AUTH_PASSWORD=change-me
# Recommended: keep sessions stable across restarts # Recommended: keep sessions stable across restarts
AUTH_SESSION_SECRET=your-random-secret AUTH_SESSION_SECRET=your-random-secret
# Optional (default: 168 hours) # Optional (default: 168 hours)
AUTH_SESSION_TTL_HOURS=168 AUTH_SESSION_TTL_HOURS=168
# Optional (default: 7)
AUTH_MIN_PASSWORD_LENGTH=7
# Optional (default: excalidash_auth) # Optional (default: excalidash_auth)
AUTH_COOKIE_NAME=excalidash_auth AUTH_COOKIE_NAME=excalidash_auth
# Optional: lax | strict | none (use "none" for cross-site hosting) # Optional: lax | strict | none (use "none" for cross-site hosting)
AUTH_COOKIE_SAMESITE=lax AUTH_COOKIE_SAMESITE=lax
``` ```
When enabled, the UI prompts for a login before accessing any drawings, Once logged in, admins can toggle user registration and grant other admins from
and all API/WebSocket traffic requires the session cookie. Settings. If no admin credentials are provided, the UI will prompt to create the
first admin account.
# Development # Development
+6
View File
@@ -5,3 +5,9 @@ DATABASE_URL=file:/app/prisma/dev.db
FRONTEND_URL=http://localhost:6767 FRONTEND_URL=http://localhost:6767
# Optional auth cookie settings: lax | strict | none # Optional auth cookie settings: lax | strict | none
AUTH_COOKIE_SAMESITE=lax 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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 { import {
buildAuthConfig, buildAuthConfig,
createAuthSessionToken, createAuthSessionToken,
generateRandomPassword,
getAuthSessionFromCookie, getAuthSessionFromCookie,
hashPassword,
isPasswordValid,
validateAuthSessionToken, validateAuthSessionToken,
verifyCredentials, verifyPassword,
} from "../auth"; } from "../auth";
describe("Auth utilities", () => { describe("Auth utilities", () => {
it("disables auth when credentials are missing", () => { it("builds auth config defaults", () => {
const config = buildAuthConfig({}); 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({ const config = buildAuthConfig({
AUTH_USERNAME: "admin",
AUTH_PASSWORD: "super-secret",
AUTH_SESSION_SECRET: "test-secret", AUTH_SESSION_SECRET: "test-secret",
}); });
expect(verifyCredentials(config, "admin", "super-secret")).toBe(true); const token = createAuthSessionToken(config, "user-123");
expect(verifyCredentials(config, "admin", "wrong")).toBe(false);
const token = createAuthSessionToken(config, "admin");
const session = validateAuthSessionToken(config, token); const session = validateAuthSessionToken(config, token);
expect(session).not.toBeNull(); expect(session).not.toBeNull();
expect(session?.username).toBe("admin"); expect(session?.userId).toBe("user-123");
}); });
it("rejects expired session tokens", () => { it("rejects expired session tokens", () => {
@@ -34,13 +39,11 @@ describe("Auth utilities", () => {
vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z")); vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
const config = buildAuthConfig({ const config = buildAuthConfig({
AUTH_USERNAME: "admin",
AUTH_PASSWORD: "secret",
AUTH_SESSION_SECRET: "test-secret", AUTH_SESSION_SECRET: "test-secret",
AUTH_SESSION_TTL_HOURS: "0.001", // ~3.6 seconds 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")); vi.setSystemTime(new Date("2025-01-01T00:00:10.000Z"));
expect(validateAuthSessionToken(config, token)).toBeNull(); expect(validateAuthSessionToken(config, token)).toBeNull();
@@ -49,14 +52,23 @@ describe("Auth utilities", () => {
it("extracts session tokens from cookies", () => { it("extracts session tokens from cookies", () => {
const config = buildAuthConfig({ const config = buildAuthConfig({
AUTH_USERNAME: "admin",
AUTH_PASSWORD: "secret",
AUTH_SESSION_SECRET: "test-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 cookieHeader = `${config.cookieName}=${encodeURIComponent(token)}; theme=dark`;
const session = getAuthSessionFromCookie(cookieHeader, config); 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 // Database integration tests
describe("Drawing API - Database Round-Trip", () => { describe("Drawing API - Database Round-Trip", () => {
const prisma = getTestPrisma(); let prisma: ReturnType<typeof getTestPrisma>;
beforeAll(async () => { beforeAll(async () => {
setupTestDb(); setupTestDb();
prisma = getTestPrisma();
await initTestDb(prisma); await initTestDb(prisma);
}); });
+29 -7
View File
@@ -5,19 +5,24 @@ import { PrismaClient } from "../generated/client";
import path from "path"; import path from "path";
import { execSync } from "child_process"; 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 // 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 * Get a test Prisma client pointing to the test database
*/ */
export const getTestPrisma = () => { export const getTestPrisma = () => {
const databaseUrl = `file:${TEST_DB_PATH}`; process.env.DATABASE_URL = TEST_DATABASE_URL;
process.env.DATABASE_URL = databaseUrl;
return new PrismaClient({ return new PrismaClient({
datasources: { datasources: {
db: { db: {
url: databaseUrl, url: TEST_DATABASE_URL,
}, },
}, },
}); });
@@ -27,14 +32,23 @@ export const getTestPrisma = () => {
* Setup the test database by running migrations * Setup the test database by running migrations
*/ */
export const setupTestDb = () => { export const setupTestDb = () => {
const databaseUrl = `file:${TEST_DB_PATH}`; process.env.DATABASE_URL = TEST_DATABASE_URL;
process.env.DATABASE_URL = databaseUrl;
// 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 // Run Prisma migrations to create the test database
try { try {
execSync("npx prisma db push --skip-generate", { execSync("npx prisma db push --skip-generate", {
cwd: path.resolve(__dirname, "../../"), cwd: path.resolve(__dirname, "../../"),
env: { ...process.env, DATABASE_URL: databaseUrl }, env: { ...process.env, DATABASE_URL: TEST_DATABASE_URL },
stdio: "pipe", stdio: "pipe",
}); });
} catch (error) { } catch (error) {
@@ -52,6 +66,8 @@ export const cleanupTestDb = async (prisma: PrismaClient) => {
await prisma.collection.deleteMany({ await prisma.collection.deleteMany({
where: { id: { not: "trash" } }, 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" }, 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 = { export type AuthConfig = {
enabled: boolean; enabled: boolean;
username: string;
password: string;
sessionTtlMs: number; sessionTtlMs: number;
cookieName: string; cookieName: string;
cookieSameSite: AuthSameSite; cookieSameSite: AuthSameSite;
secret: Buffer; secret: Buffer;
minPasswordLength: number;
}; };
export type AuthSession = { export type AuthSession = {
username: string; userId: string;
iat: number; iat: number;
exp: number; exp: number;
}; };
@@ -46,6 +45,14 @@ const parseSessionTtlHours = (rawValue?: string): number => {
return parsed; 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 => { const parseSameSite = (rawValue?: string): AuthSameSite => {
if (!rawValue) return DEFAULT_COOKIE_SAMESITE; if (!rawValue) return DEFAULT_COOKIE_SAMESITE;
const normalized = rawValue.trim().toLowerCase(); 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 => { 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 sessionTtlHours = parseSessionTtlHours(env.AUTH_SESSION_TTL_HOURS);
const cookieName = (env.AUTH_COOKIE_NAME || DEFAULT_COOKIE_NAME).trim(); const cookieName = (env.AUTH_COOKIE_NAME || DEFAULT_COOKIE_NAME).trim();
const cookieSameSite = parseSameSite(env.AUTH_COOKIE_SAMESITE); const cookieSameSite = parseSameSite(env.AUTH_COOKIE_SAMESITE);
const minPasswordLength = parseMinPasswordLength(env.AUTH_MIN_PASSWORD_LENGTH);
return { return {
enabled, enabled: true,
username,
password,
sessionTtlMs: sessionTtlHours * 60 * 60 * 1000, sessionTtlMs: sessionTtlHours * 60 * 60 * 1000,
cookieName: cookieName.length > 0 ? cookieName : DEFAULT_COOKIE_NAME, cookieName: cookieName.length > 0 ? cookieName : DEFAULT_COOKIE_NAME,
cookieSameSite, cookieSameSite,
secret: resolveAuthSecret(enabled, env), secret: resolveAuthSecret(true, env),
minPasswordLength,
}; };
}; };
const signToken = (secret: Buffer, payloadB64: string): Buffer => const signToken = (secret: Buffer, payloadB64: string): Buffer =>
crypto.createHmac("sha256", secret).update(payloadB64, "utf8").digest(); crypto.createHmac("sha256", secret).update(payloadB64, "utf8").digest();
const safeCompare = (left: string, right: string): boolean => { const PASSWORD_SALT_BYTES = 16;
const leftHash = crypto.createHash("sha256").update(left, "utf8").digest(); const PASSWORD_HASH_BYTES = 64;
const rightHash = crypto.createHash("sha256").update(right, "utf8").digest(); const PASSWORD_SCRYPT_OPTIONS = {
return crypto.timingSafeEqual(leftHash, rightHash); N: 16384,
r: 8,
p: 1,
maxmem: 64 * 1024 * 1024,
}; };
export const verifyCredentials = ( export const hashPassword = (password: string): string => {
config: AuthConfig, const salt = crypto.randomBytes(PASSWORD_SALT_BYTES);
inputUsername: string, const derived = crypto.scryptSync(password, salt, PASSWORD_HASH_BYTES, PASSWORD_SCRYPT_OPTIONS);
inputPassword: string return `${salt.toString("hex")}:${derived.toString("hex")}`;
): boolean => {
if (!config.enabled) return false;
return safeCompare(config.username, inputUsername) && safeCompare(config.password, inputPassword);
}; };
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) { if (!config.enabled) {
throw new Error("Authentication is not enabled."); throw new Error("Authentication is not enabled.");
} }
const issuedAt = Date.now(); const issuedAt = Date.now();
const payload: AuthSession = { const payload: AuthSession = {
username, userId,
iat: issuedAt, iat: issuedAt,
exp: issuedAt + config.sessionTtlMs, exp: issuedAt + config.sessionTtlMs,
}; };
@@ -151,7 +191,7 @@ export const validateAuthSessionToken = (
const payloadJson = base64UrlDecode(payloadB64).toString("utf8"); const payloadJson = base64UrlDecode(payloadB64).toString("utf8");
const payload = JSON.parse(payloadJson) as Partial<AuthSession>; const payload = JSON.parse(payloadJson) as Partial<AuthSession>;
if ( if (
typeof payload.username !== "string" || typeof payload.userId !== "string" ||
typeof payload.iat !== "number" || typeof payload.iat !== "number" ||
typeof payload.exp !== "number" typeof payload.exp !== "number"
) { ) {
@@ -190,6 +230,9 @@ export const getAuthSessionFromCookie = (
return validateAuthSessionToken(config, token); return validateAuthSessionToken(config, token);
}; };
export const buildAuthIdentifier = (user: { username?: string | null; email?: string | null }) =>
user.username || user.email || "";
export const buildAuthCookieOptions = ( export const buildAuthCookieOptions = (
secure: boolean, secure: boolean,
sameSite: AuthSameSite, 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 { defineConfig, devices } from "@playwright/test";
import path from "path";
import os from "os";
// Centralized test environment URLs // Centralized test environment URLs
const FRONTEND_PORT = 5173; const FRONTEND_PORT = 5173;
const BACKEND_PORT = 8000; const BACKEND_PORT = 8000;
const FRONTEND_URL = process.env.BASE_URL || `http://localhost:${FRONTEND_PORT}`; 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_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 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_USERNAME = AUTH_USERNAME;
process.env.AUTH_PASSWORD = AUTH_PASSWORD; process.env.AUTH_PASSWORD = AUTH_PASSWORD;
process.env.AUTH_SESSION_SECRET = AUTH_SESSION_SECRET; 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 * Playwright configuration for E2E browser testing
@@ -26,7 +36,7 @@ export default defineConfig({
testDir: "./tests", testDir: "./tests",
// Run tests in parallel // Run tests in parallel
fullyParallel: true, fullyParallel: false,
// Fail the build on test.only() in CI // Fail the build on test.only() in CI
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
@@ -35,7 +45,7 @@ export default defineConfig({
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
// Limit parallel workers in CI // Limit parallel workers in CI
workers: process.env.CI ? 1 : undefined, workers: process.env.CI ? 1 : 1,
// Reporter configuration // Reporter configuration
reporter: [ reporter: [
@@ -65,6 +75,9 @@ export default defineConfig({
// Base URL for page.goto() // Base URL for page.goto()
baseURL: FRONTEND_URL, baseURL: FRONTEND_URL,
// Load shared auth state
storageState: path.resolve(__dirname, "tests/.auth/storageState.json"),
// Collect trace on first retry // Collect trace on first retry
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) // Run local dev servers before tests (skip if NO_SERVER or CI)
webServer: (process.env.CI || process.env.NO_SERVER === "true") ? undefined : [ webServer: (process.env.CI || process.env.NO_SERVER === "true")
{ ? undefined
command: "cd ../backend && npm run dev", : [
url: `${BACKEND_URL}/health`, {
reuseExistingServer: true, command: "cd ../backend && npx prisma db push && npx ts-node src/index.ts",
timeout: 120000, url: `${BACKEND_URL}/health`,
stdout: "pipe", reuseExistingServer: true,
stderr: "pipe", timeout: 120000,
env: { stdout: "pipe",
// Prisma resolves relative SQLite paths from the schema directory (backend/prisma). stderr: "pipe",
// Using `file:./dev.db` avoids accidentally creating `prisma/prisma/dev.db`. env: {
DATABASE_URL: "file:./dev.db", // Prisma resolves relative SQLite paths from the schema directory (backend/prisma).
FRONTEND_URL, DATABASE_URL,
CSRF_MAX_REQUESTS: "1000", FRONTEND_URL,
AUTH_USERNAME, CSRF_MAX_REQUESTS: "10000",
AUTH_PASSWORD, AUTH_USERNAME,
AUTH_SESSION_SECRET, AUTH_PASSWORD,
}, AUTH_MIN_PASSWORD_LENGTH: "7",
}, AUTH_SESSION_SECRET,
{ AUTH_SESSION_TTL_HOURS: "4",
command: "cd ../frontend && npm run dev -- --host", RATE_LIMIT_MAX_REQUESTS: "20000",
url: FRONTEND_URL, NODE_ENV: "e2e",
reuseExistingServer: true, TS_NODE_TRANSPILE_ONLY: "1",
timeout: 120000, },
stdout: "pipe", },
stderr: "pipe", {
}, 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"; import { test, expect } from "./fixtures";
test.describe("Authentication", () => { test.describe("Authentication", () => {
test.use({ skipAuth: true });
test("should require login and allow logout", async ({ page }) => { test("should require login and allow logout", async ({ page }) => {
const username = process.env.AUTH_USERNAME || "admin"; 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.context().clearCookies();
await page.goto("/"); 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.getByLabel("Password").fill(password);
await page.getByRole("button", { name: "Sign in" }).click(); await page.getByRole("button", { name: "Sign in" }).click();
@@ -22,6 +25,8 @@ test.describe("Authentication", () => {
await expect(logoutButton).toBeVisible(); await expect(logoutButton).toBeVisible();
await logoutButton.click(); 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 page.waitForLoadState("networkidle");
await applyDashboardSearch(page, drawingName); await applyDashboardSearch(page, drawingName);
const cardLocator = await ensureCardVisible(page, createdDrawing.id); let cardLocator = await ensureCardVisible(page, createdDrawing.id);
await ensureCardSelected(page, createdDrawing.id); await ensureCardSelected(page, createdDrawing.id);
await page.getByTitle("Move to Trash").click(); await page.getByTitle("Move to Trash").click();
await expect(cardLocator).toHaveCount(0); await expect(cardLocator).toHaveCount(0);
await page.getByRole("button", { name: /^Trash$/ }).click(); 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 ensureCardSelected(page, createdDrawing.id);
await page.getByTitle("Delete Permanently").click(); await page.getByTitle("Delete Permanently").click();
await page.getByRole("button", { name: /Delete \d+ Drawings/ }).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}`); const response = await request.get(`${API_URL}/drawings/${createdDrawing.id}`);
expect(response.status()).toBe(404); expect(response.status()).toBe(404);
+2
View File
@@ -52,6 +52,8 @@ test.describe("Drag and Drop - Collections", () => {
await page.goto("/"); await page.goto("/");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await page.getByPlaceholder("Search drawings...").fill(drawing.name);
await page.waitForTimeout(500);
// Find the drawing card // Find the drawing card
const card = page.locator(`#drawing-card-${drawing.id}`); const card = page.locator(`#drawing-card-${drawing.id}`);
+2 -1
View File
@@ -3,6 +3,7 @@ import {
API_URL, API_URL,
createDrawing, createDrawing,
deleteDrawing, deleteDrawing,
ensureAuthenticated,
getCsrfHeaders, getCsrfHeaders,
listDrawings, listDrawings,
deleteCollection, deleteCollection,
@@ -381,9 +382,9 @@ test.describe("Database Import Verification", () => {
test("should verify SQLite import endpoint exists", async ({ request }) => { test("should verify SQLite import endpoint exists", async ({ request }) => {
// Test that the verification endpoint responds // Test that the verification endpoint responds
// We don't actually import a database as that would affect the test environment // 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`, { const response = await request.post(`${API_URL}/import/sqlite/verify`, {
headers: await getCsrfHeaders(request), headers: await getCsrfHeaders(request),
// Send empty form data to test endpoint exists
multipart: { multipart: {
db: { db: {
name: "test.sqlite", name: "test.sqlite",
+57 -18
View File
@@ -1,24 +1,63 @@
import { test as base, expect } from "@playwright/test"; import { test as base, expect } from "@playwright/test";
import { ensurePageAuthenticated } from "./helpers/auth";
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin"; type Fixtures = {
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin"; skipAuth: boolean;
};
export const test = base; export const test = base.extend<Fixtures>({
skipAuth: [false, { option: true }],
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 });
}
}); });
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 }; 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 // Default ports match the Playwright config
const DEFAULT_BACKEND_PORT = 8000; 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}`; export const API_URL = process.env.API_URL || `http://localhost:${DEFAULT_BACKEND_PORT}`;
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin"; 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 // Track authenticated API contexts
const authenticatedContexts = new WeakSet<APIRequestContext>(); const authenticatedContexts = new WeakSet<APIRequestContext>();
@@ -20,10 +22,17 @@ export async function ensureAuthenticated(request: APIRequestContext): Promise<v
// Check current auth status // Check current auth status
const statusResp = await request.get(`${API_URL}/auth/status`); const statusResp = await request.get(`${API_URL}/auth/status`);
if (!statusResp.ok()) { if (!statusResp.ok()) {
if (statusResp.status() === 401) {
authenticatedContexts.delete(request);
}
throw new Error(`Failed to check auth status: ${statusResp.status()}`); 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) { if (!status.enabled) {
// Auth is disabled, mark as "authenticated" // Auth is disabled, mark as "authenticated"
@@ -36,19 +45,53 @@ export async function ensureAuthenticated(request: APIRequestContext): Promise<v
return; return;
} }
// Need to login if (status.bootstrapRequired) {
const csrfHeaders = await getCsrfHeaders(request); const bootstrapHeaders = await withCsrfHeaders(request, {
const loginResp = await request.post(`${API_URL}/auth/login`, {
headers: {
"Content-Type": "application/json", "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: { data: {
username: AUTH_USERNAME, username: AUTH_USERNAME,
password: AUTH_PASSWORD, 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()) { if (!loginResp.ok()) {
const text = await loginResp.text(); const text = await loginResp.text();
throw new Error(`API authentication failed: ${loginResp.status()} ${text}`); throw new Error(`API authentication failed: ${loginResp.status()} ${text}`);
@@ -67,12 +110,18 @@ type CsrfInfo = {
headerName: string; 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. // Cache CSRF tokens per Playwright request context so parallel tests don't race.
const csrfInfoByRequest = new WeakMap<APIRequestContext, CsrfInfo>(); const csrfInfoByRequest = new WeakMap<APIRequestContext, CsrfInfo>();
const csrfFetchByRequest = new WeakMap<APIRequestContext, Promise<CsrfInfo>>(); const csrfFetchByRequest = new WeakMap<APIRequestContext, Promise<CsrfInfo>>();
const fetchCsrfInfo = async (request: 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()) { if (!response.ok()) {
const text = await response.text(); const text = await response.text();
throw new Error( throw new Error(
@@ -127,6 +176,11 @@ const refreshCsrfInfo = async (request: APIRequestContext): Promise<CsrfInfo> =>
return promise; return promise;
}; };
export const refreshCsrfToken = async (request: APIRequestContext): Promise<void> => {
authenticatedContexts.delete(request);
await refreshCsrfInfo(request);
};
export async function getCsrfHeaders( export async function getCsrfHeaders(
request: APIRequestContext request: APIRequestContext
): Promise<Record<string, string>> { ): Promise<Record<string, string>> {
@@ -138,6 +192,7 @@ const withCsrfHeaders = async (
request: APIRequestContext, request: APIRequestContext,
headers: Record<string, string> = {} headers: Record<string, string> = {}
): Promise<Record<string, string>> => ({ ): Promise<Record<string, string>> => ({
...buildBaseHeaders(request),
...headers, ...headers,
...(await getCsrfHeaders(request)), ...(await getCsrfHeaders(request)),
}); });
@@ -199,6 +254,26 @@ export async function createDrawing(
data: payload, 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 // 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). // a different clientId (rare, but can happen under parallelism / CI proxies).
if (!response.ok() && response.status() === 403) { if (!response.ok() && response.status() === 403) {
@@ -216,7 +291,14 @@ export async function createDrawing(
const text = await response.text(); const text = await response.text();
throw new Error(`Failed to create drawing: ${response.status()} ${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( export async function getDrawing(
@@ -224,7 +306,20 @@ export async function getDrawing(
id: string id: string
): Promise<DrawingRecord> { ): Promise<DrawingRecord> {
await ensureAuthenticated(request); 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); expect(response.ok()).toBe(true);
return (await response.json()) as DrawingRecord; return (await response.json()) as DrawingRecord;
} }
@@ -234,15 +329,25 @@ export async function deleteDrawing(
id: string id: string
): Promise<void> { ): Promise<void> {
await ensureAuthenticated(request); await ensureAuthenticated(request);
const headers = await withCsrfHeaders(request); let headers = await withCsrfHeaders(request);
let response = await request.delete(`${API_URL}/drawings/${id}`, { headers }); 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) { if (!response.ok() && response.status() === 403) {
await refreshCsrfInfo(request); await refreshCsrfInfo(request);
const retryHeaders = await withCsrfHeaders(request); headers = await withCsrfHeaders(request);
response = await request.delete(`${API_URL}/drawings/${id}`, { response = await request.delete(`${API_URL}/drawings/${id}`, { headers });
headers: retryHeaders,
});
} }
if (!response.ok()) { if (!response.ok()) {
@@ -252,6 +357,12 @@ export async function deleteDrawing(
throw new Error(`Failed to delete drawing ${id}: ${response.status()} ${text}`); 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( export async function listDrawings(
@@ -270,9 +381,25 @@ export async function listDrawings(
if (options.includeData) params.set("includeData", "true"); if (options.includeData) params.set("includeData", "true");
const query = params.toString(); const query = params.toString();
const response = await request.get( let response = await request.get(
`${API_URL}/drawings${query ? `?${query}` : ""}` `${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); expect(response.ok()).toBe(true);
return (await response.json()) as DrawingRecord[]; return (await response.json()) as DrawingRecord[];
} }
@@ -289,6 +416,26 @@ export async function createCollection(
data: { name }, 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) { if (!response.ok() && response.status() === 403) {
await refreshCsrfInfo(request); await refreshCsrfInfo(request);
const retryHeaders = await withCsrfHeaders(request, { const retryHeaders = await withCsrfHeaders(request, {
@@ -308,7 +455,19 @@ export async function listCollections(
request: APIRequestContext request: APIRequestContext
): Promise<CollectionRecord[]> { ): Promise<CollectionRecord[]> {
await ensureAuthenticated(request); 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); expect(response.ok()).toBe(true);
return (await response.json()) as CollectionRecord[]; return (await response.json()) as CollectionRecord[];
} }
@@ -318,15 +477,25 @@ export async function deleteCollection(
id: string id: string
): Promise<void> { ): Promise<void> {
await ensureAuthenticated(request); await ensureAuthenticated(request);
const headers = await withCsrfHeaders(request); let headers = await withCsrfHeaders(request);
let response = await request.delete(`${API_URL}/collections/${id}`, { headers }); 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) { if (!response.ok() && response.status() === 403) {
await refreshCsrfInfo(request); await refreshCsrfInfo(request);
const retryHeaders = await withCsrfHeaders(request); headers = await withCsrfHeaders(request);
response = await request.delete(`${API_URL}/collections/${id}`, { response = await request.delete(`${API_URL}/collections/${id}`, { headers });
headers: retryHeaders,
});
} }
if (!response.ok()) { if (!response.ok()) {
@@ -335,4 +504,10 @@ export async function deleteCollection(
throw new Error(`Failed to delete collection ${id}: ${response.status()} ${text}`); 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 { 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 = { type AuthStatus = {
enabled: boolean; enabled: boolean;
authenticated: boolean; authenticated: boolean;
bootstrapRequired?: boolean;
}; };
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin"; 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 authStatusCache = new WeakMap<APIRequestContext, AuthStatus>();
const fetchAuthStatus = async (request: APIRequestContext): Promise<AuthStatus> => { const fetchAuthStatus = async (request: APIRequestContext): Promise<AuthStatus> => {
const cached = authStatusCache.get(request); const maxAttempts = 3;
// Only use cache if we're already authenticated
if (cached?.authenticated) return cached;
const response = await request.get(`${API_URL}/auth/status`); for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
if (!response.ok()) { try {
const text = await response.text(); const response = await request.get(`${BASE_URL}/auth/status`);
throw new Error(`Failed to fetch auth status: ${response.status()} ${text}`); 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; throw new Error("Failed to fetch auth status");
authStatusCache.set(request, data);
return data;
};
const setAuthStatus = (request: APIRequestContext, status: AuthStatus) => {
authStatusCache.set(request, status);
}; };
export const ensureApiAuthenticated = async (request: APIRequestContext) => { export const ensureApiAuthenticated = async (request: APIRequestContext) => {
@@ -37,11 +47,44 @@ export const ensureApiAuthenticated = async (request: APIRequestContext) => {
return; return;
} }
const headers = await getCsrfHeaders(request); if (status.bootstrapRequired) {
const response = await request.post(`${API_URL}/auth/login`, { 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: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...headers, ...(await getCsrfHeaders(request)),
}, },
data: { data: {
username: AUTH_USERNAME, 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()) { if (!response.ok()) {
const text = await response.text(); const text = await response.text();
throw new Error(`Failed to authenticate test session: ${response.status()} ${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); 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, API_URL,
createDrawing, createDrawing,
deleteDrawing, deleteDrawing,
ensureAuthenticated,
getCsrfHeaders, getCsrfHeaders,
getDrawing, getDrawing,
} from "./helpers/api"; } from "./helpers/api";
@@ -199,6 +200,7 @@ test.describe("Security - Malicious Content Blocking", () => {
}, },
}; };
await ensureAuthenticated(request);
const response = await request.post(`${API_URL}/drawings`, { const response = await request.post(`${API_URL}/drawings`, {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -225,6 +227,7 @@ test.describe("Security - Malicious Content Blocking", () => {
expect(savedFiles["malicious-image"].dataURL).not.toContain("javascript:"); expect(savedFiles["malicious-image"].dataURL).not.toContain("javascript:");
// Cleanup // Cleanup
await ensureAuthenticated(request);
await request.delete(`${API_URL}/drawings/${drawing.id}`, { await request.delete(`${API_URL}/drawings/${drawing.id}`, {
headers: await getCsrfHeaders(request), 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`, { const response = await request.post(`${API_URL}/drawings`, {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -266,6 +270,7 @@ test.describe("Security - Malicious Content Blocking", () => {
expect(savedFiles["malicious-image"].dataURL).not.toContain("<script>"); expect(savedFiles["malicious-image"].dataURL).not.toContain("<script>");
// Cleanup // Cleanup
await ensureAuthenticated(request);
await request.delete(`${API_URL}/drawings/${drawing.id}`, { await request.delete(`${API_URL}/drawings/${drawing.id}`, {
headers: await getCsrfHeaders(request), 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 = { export type AuthStatus = {
enabled: boolean; enabled: boolean;
authenticated: 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; let unauthorizedHandler: (() => void) | null = null;
@@ -133,6 +135,43 @@ export const logout = async () => {
return response.data; 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 => { const coerceTimestamp = (value: string | number | Date): number => {
if (typeof value === "number") return value; if (typeof value === "number") return value;
if (value instanceof Date) return value.getTime(); if (value instanceof Date) return value.getTime();
+57 -2
View File
@@ -12,7 +12,9 @@ import * as api from "../api";
type AuthState = { type AuthState = {
enabled: boolean; enabled: boolean;
authenticated: 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; loading: boolean;
statusError: string | null; statusError: string | null;
}; };
@@ -21,6 +23,10 @@ type AuthContextValue = {
state: AuthState; state: AuthState;
login: (username: string, password: string) => Promise<void>; login: (username: string, password: string) => Promise<void>;
logout: () => 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>; refreshStatus: () => Promise<void>;
}; };
@@ -30,6 +36,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const [state, setState] = useState<AuthState>({ const [state, setState] = useState<AuthState>({
enabled: false, enabled: false,
authenticated: false, authenticated: false,
registrationEnabled: false,
bootstrapRequired: false,
user: null, user: null,
loading: true, loading: true,
statusError: null, statusError: null,
@@ -45,6 +53,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setState({ setState({
enabled: status.enabled, enabled: status.enabled,
authenticated: status.authenticated, authenticated: status.authenticated,
registrationEnabled: status.registrationEnabled,
bootstrapRequired: status.bootstrapRequired,
user: status.user, user: status.user,
loading: false, loading: false,
statusError: null, statusError: null,
@@ -89,14 +99,59 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
await refreshStatus(); await refreshStatus();
}, [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>( const value = useMemo<AuthContextValue>(
() => ({ () => ({
state, state,
login, login,
logout, logout,
register,
bootstrapAdmin,
setRegistrationEnabled,
updateUserRole,
refreshStatus, refreshStatus,
}), }),
[state, login, logout, refreshStatus] [
state,
login,
logout,
register,
bootstrapAdmin,
setRegistrationEnabled,
updateUserRole,
refreshStatus,
]
); );
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; 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"; import { useAuth } from "../context/AuthContext";
export const Login: React.FC = () => { export const Login: React.FC = () => {
const { login } = useAuth(); const { state, login, register, bootstrapAdmin } = useAuth();
const [username, setUsername] = useState(""); const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showRegister, setShowRegister] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); 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) => { const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
setError(null); setError(null);
setIsSubmitting(true); setIsSubmitting(true);
try { 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) { } catch (err) {
console.error("Login failed:", err); console.error("Auth failed:", err);
setError("Invalid username or password."); setError("Unable to complete authentication.");
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -34,20 +66,24 @@ export const Login: React.FC = () => {
ExcaliDash ExcaliDash
</h1> </h1>
<p className="mt-2 text-sm text-slate-500 dark:text-neutral-400 font-medium"> <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> </p>
</div> </div>
<form onSubmit={handleSubmit} className="mt-8 space-y-5"> <form onSubmit={handleSubmit} className="mt-8 space-y-5">
<label className="block text-sm font-semibold text-slate-700 dark:text-neutral-200"> <label className="block text-sm font-semibold text-slate-700 dark:text-neutral-200">
Username Username or Email
<input <input
type="text" type="text"
name="username" name="identifier"
autoComplete="username" autoComplete="username"
required required
value={username} value={identifier}
onChange={(event) => setUsername(event.target.value)} 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" 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> </label>
@@ -57,7 +93,7 @@ export const Login: React.FC = () => {
<input <input
type="password" type="password"
name="password" name="password"
autoComplete="current-password" autoComplete={showRegister || isBootstrap ? "new-password" : "current-password"}
required required
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setPassword(event.target.value)}
@@ -65,6 +101,21 @@ export const Login: React.FC = () => {
/> />
</label> </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 && ( {error && (
<p className="text-sm text-rose-600 dark:text-rose-400 font-semibold"> <p className="text-sm text-rose-600 dark:text-rose-400 font-semibold">
{error} {error}
@@ -76,8 +127,34 @@ export const Login: React.FC = () => {
disabled={isSubmitting} 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" 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> </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> </form>
</div> </div>
</div> </div>

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