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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+59
-32
@@ -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"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
@@ -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
@@ -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,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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user