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:
+201
-26
@@ -1,4 +1,6 @@
|
||||
import { APIRequestContext, expect } from "@playwright/test";
|
||||
import { APIRequestContext } from "@playwright/test";
|
||||
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
// Default ports match the Playwright config
|
||||
const DEFAULT_BACKEND_PORT = 8000;
|
||||
@@ -6,7 +8,7 @@ const DEFAULT_BACKEND_PORT = 8000;
|
||||
export const API_URL = process.env.API_URL || `http://localhost:${DEFAULT_BACKEND_PORT}`;
|
||||
|
||||
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
|
||||
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin";
|
||||
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin123";
|
||||
|
||||
// Track authenticated API contexts
|
||||
const authenticatedContexts = new WeakSet<APIRequestContext>();
|
||||
@@ -20,11 +22,18 @@ export async function ensureAuthenticated(request: APIRequestContext): Promise<v
|
||||
// Check current auth status
|
||||
const statusResp = await request.get(`${API_URL}/auth/status`);
|
||||
if (!statusResp.ok()) {
|
||||
if (statusResp.status() === 401) {
|
||||
authenticatedContexts.delete(request);
|
||||
}
|
||||
throw new Error(`Failed to check auth status: ${statusResp.status()}`);
|
||||
}
|
||||
|
||||
const status = (await statusResp.json()) as { enabled: boolean; authenticated: boolean };
|
||||
|
||||
const status = (await statusResp.json()) as {
|
||||
enabled: boolean;
|
||||
authenticated: boolean;
|
||||
bootstrapRequired?: boolean;
|
||||
};
|
||||
|
||||
if (!status.enabled) {
|
||||
// Auth is disabled, mark as "authenticated"
|
||||
authenticatedContexts.add(request);
|
||||
@@ -36,19 +45,53 @@ export async function ensureAuthenticated(request: APIRequestContext): Promise<v
|
||||
return;
|
||||
}
|
||||
|
||||
// Need to login
|
||||
const csrfHeaders = await getCsrfHeaders(request);
|
||||
const loginResp = await request.post(`${API_URL}/auth/login`, {
|
||||
headers: {
|
||||
if (status.bootstrapRequired) {
|
||||
const bootstrapHeaders = await withCsrfHeaders(request, {
|
||||
"Content-Type": "application/json",
|
||||
...csrfHeaders,
|
||||
},
|
||||
});
|
||||
const bootstrapResp = await request.post(`${API_URL}/auth/bootstrap`, {
|
||||
headers: bootstrapHeaders,
|
||||
data: {
|
||||
username: AUTH_USERNAME,
|
||||
password: AUTH_PASSWORD,
|
||||
},
|
||||
});
|
||||
|
||||
if (!bootstrapResp.ok()) {
|
||||
const text = await bootstrapResp.text();
|
||||
throw new Error(`API bootstrap failed: ${bootstrapResp.status()} ${text}`);
|
||||
}
|
||||
|
||||
authenticatedContexts.add(request);
|
||||
return;
|
||||
}
|
||||
|
||||
// Need to login
|
||||
let loginHeaders = await withCsrfHeaders(request, {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
let loginResp = await request.post(`${API_URL}/auth/login`, {
|
||||
headers: loginHeaders,
|
||||
data: {
|
||||
username: AUTH_USERNAME,
|
||||
password: AUTH_PASSWORD,
|
||||
},
|
||||
});
|
||||
|
||||
if (!loginResp.ok() && loginResp.status() === 403) {
|
||||
await refreshCsrfInfo(request);
|
||||
loginHeaders = await withCsrfHeaders(request, {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
loginResp = await request.post(`${API_URL}/auth/login`, {
|
||||
headers: loginHeaders,
|
||||
data: {
|
||||
username: AUTH_USERNAME,
|
||||
password: AUTH_PASSWORD,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!loginResp.ok()) {
|
||||
const text = await loginResp.text();
|
||||
throw new Error(`API authentication failed: ${loginResp.status()} ${text}`);
|
||||
@@ -67,12 +110,18 @@ type CsrfInfo = {
|
||||
headerName: string;
|
||||
};
|
||||
|
||||
const buildBaseHeaders = (_request: APIRequestContext): Record<string, string> => ({
|
||||
origin: process.env.BASE_URL || "http://localhost:5173",
|
||||
});
|
||||
|
||||
// Cache CSRF tokens per Playwright request context so parallel tests don't race.
|
||||
const csrfInfoByRequest = new WeakMap<APIRequestContext, CsrfInfo>();
|
||||
const csrfFetchByRequest = new WeakMap<APIRequestContext, Promise<CsrfInfo>>();
|
||||
|
||||
const fetchCsrfInfo = async (request: APIRequestContext): Promise<CsrfInfo> => {
|
||||
const response = await request.get(`${API_URL}/csrf-token`);
|
||||
const response = await request.get(`${API_URL}/csrf-token`, {
|
||||
headers: buildBaseHeaders(request),
|
||||
});
|
||||
if (!response.ok()) {
|
||||
const text = await response.text();
|
||||
throw new Error(
|
||||
@@ -127,6 +176,11 @@ const refreshCsrfInfo = async (request: APIRequestContext): Promise<CsrfInfo> =>
|
||||
return promise;
|
||||
};
|
||||
|
||||
export const refreshCsrfToken = async (request: APIRequestContext): Promise<void> => {
|
||||
authenticatedContexts.delete(request);
|
||||
await refreshCsrfInfo(request);
|
||||
};
|
||||
|
||||
export async function getCsrfHeaders(
|
||||
request: APIRequestContext
|
||||
): Promise<Record<string, string>> {
|
||||
@@ -138,6 +192,7 @@ const withCsrfHeaders = async (
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string> = {}
|
||||
): Promise<Record<string, string>> => ({
|
||||
...buildBaseHeaders(request),
|
||||
...headers,
|
||||
...(await getCsrfHeaders(request)),
|
||||
});
|
||||
@@ -190,7 +245,7 @@ export async function createDrawing(
|
||||
overrides: CreateDrawingOptions = {}
|
||||
): Promise<DrawingRecord> {
|
||||
await ensureAuthenticated(request);
|
||||
|
||||
|
||||
const payload = { ...defaultDrawingPayload(), ...overrides };
|
||||
const headers = await withCsrfHeaders(request, { "Content-Type": "application/json" });
|
||||
|
||||
@@ -199,6 +254,26 @@ export async function createDrawing(
|
||||
data: payload,
|
||||
});
|
||||
|
||||
if (!response.ok() && response.status() === 401) {
|
||||
authenticatedContexts.delete(request);
|
||||
await ensureAuthenticated(request);
|
||||
const retryHeaders = await withCsrfHeaders(request, {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
response = await request.post(`${API_URL}/drawings`, {
|
||||
headers: retryHeaders,
|
||||
data: payload,
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok() && response.status() === 503) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
response = await request.post(`${API_URL}/drawings`, {
|
||||
headers,
|
||||
data: payload,
|
||||
});
|
||||
}
|
||||
|
||||
// Retry once with a fresh token in case it expired or the cache was primed under
|
||||
// a different clientId (rare, but can happen under parallelism / CI proxies).
|
||||
if (!response.ok() && response.status() === 403) {
|
||||
@@ -216,7 +291,14 @@ export async function createDrawing(
|
||||
const text = await response.text();
|
||||
throw new Error(`Failed to create drawing: ${response.status()} ${text}`);
|
||||
}
|
||||
return (await response.json()) as DrawingRecord;
|
||||
|
||||
const created = (await response.json()) as DrawingRecord;
|
||||
try {
|
||||
await request.get(`${API_URL}/drawings/${created.id}`);
|
||||
} catch {
|
||||
// Ignore warm-up failures to keep tests resilient.
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function getDrawing(
|
||||
@@ -224,7 +306,20 @@ export async function getDrawing(
|
||||
id: string
|
||||
): Promise<DrawingRecord> {
|
||||
await ensureAuthenticated(request);
|
||||
const response = await request.get(`${API_URL}/drawings/${id}`);
|
||||
|
||||
let response = await request.get(`${API_URL}/drawings/${id}`);
|
||||
|
||||
if (!response.ok() && response.status() === 401) {
|
||||
authenticatedContexts.delete(request);
|
||||
await ensureAuthenticated(request);
|
||||
response = await request.get(`${API_URL}/drawings/${id}`);
|
||||
}
|
||||
|
||||
if (!response.ok() && response.status() === 503) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
response = await request.get(`${API_URL}/drawings/${id}`);
|
||||
}
|
||||
|
||||
expect(response.ok()).toBe(true);
|
||||
return (await response.json()) as DrawingRecord;
|
||||
}
|
||||
@@ -234,15 +329,25 @@ export async function deleteDrawing(
|
||||
id: string
|
||||
): Promise<void> {
|
||||
await ensureAuthenticated(request);
|
||||
const headers = await withCsrfHeaders(request);
|
||||
let headers = await withCsrfHeaders(request);
|
||||
let response = await request.delete(`${API_URL}/drawings/${id}`, { headers });
|
||||
|
||||
if (!response.ok() && response.status() === 401) {
|
||||
authenticatedContexts.delete(request);
|
||||
await ensureAuthenticated(request);
|
||||
headers = await withCsrfHeaders(request);
|
||||
response = await request.delete(`${API_URL}/drawings/${id}`, { headers });
|
||||
}
|
||||
|
||||
if (!response.ok() && response.status() === 503) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
response = await request.delete(`${API_URL}/drawings/${id}`, { headers });
|
||||
}
|
||||
|
||||
if (!response.ok() && response.status() === 403) {
|
||||
await refreshCsrfInfo(request);
|
||||
const retryHeaders = await withCsrfHeaders(request);
|
||||
response = await request.delete(`${API_URL}/drawings/${id}`, {
|
||||
headers: retryHeaders,
|
||||
});
|
||||
headers = await withCsrfHeaders(request);
|
||||
response = await request.delete(`${API_URL}/drawings/${id}`, { headers });
|
||||
}
|
||||
|
||||
if (!response.ok()) {
|
||||
@@ -252,6 +357,12 @@ export async function deleteDrawing(
|
||||
throw new Error(`Failed to delete drawing ${id}: ${response.status()} ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await request.get(`${API_URL}/drawings/${id}`);
|
||||
} catch {
|
||||
// Ignore cache warm-up failures.
|
||||
}
|
||||
}
|
||||
|
||||
export async function listDrawings(
|
||||
@@ -270,9 +381,25 @@ export async function listDrawings(
|
||||
if (options.includeData) params.set("includeData", "true");
|
||||
|
||||
const query = params.toString();
|
||||
const response = await request.get(
|
||||
let response = await request.get(
|
||||
`${API_URL}/drawings${query ? `?${query}` : ""}`
|
||||
);
|
||||
|
||||
if (!response.ok() && response.status() === 401) {
|
||||
authenticatedContexts.delete(request);
|
||||
await ensureAuthenticated(request);
|
||||
response = await request.get(
|
||||
`${API_URL}/drawings${query ? `?${query}` : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok() && response.status() === 503) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
response = await request.get(
|
||||
`${API_URL}/drawings${query ? `?${query}` : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
expect(response.ok()).toBe(true);
|
||||
return (await response.json()) as DrawingRecord[];
|
||||
}
|
||||
@@ -289,6 +416,26 @@ export async function createCollection(
|
||||
data: { name },
|
||||
});
|
||||
|
||||
if (!response.ok() && response.status() === 401) {
|
||||
authenticatedContexts.delete(request);
|
||||
await ensureAuthenticated(request);
|
||||
const retryHeaders = await withCsrfHeaders(request, {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
response = await request.post(`${API_URL}/collections`, {
|
||||
headers: retryHeaders,
|
||||
data: { name },
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok() && response.status() === 503) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
response = await request.post(`${API_URL}/collections`, {
|
||||
headers,
|
||||
data: { name },
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok() && response.status() === 403) {
|
||||
await refreshCsrfInfo(request);
|
||||
const retryHeaders = await withCsrfHeaders(request, {
|
||||
@@ -308,7 +455,19 @@ export async function listCollections(
|
||||
request: APIRequestContext
|
||||
): Promise<CollectionRecord[]> {
|
||||
await ensureAuthenticated(request);
|
||||
const response = await request.get(`${API_URL}/collections`);
|
||||
let response = await request.get(`${API_URL}/collections`);
|
||||
|
||||
if (!response.ok() && response.status() === 401) {
|
||||
authenticatedContexts.delete(request);
|
||||
await ensureAuthenticated(request);
|
||||
response = await request.get(`${API_URL}/collections`);
|
||||
}
|
||||
|
||||
if (!response.ok() && response.status() === 503) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
response = await request.get(`${API_URL}/collections`);
|
||||
}
|
||||
|
||||
expect(response.ok()).toBe(true);
|
||||
return (await response.json()) as CollectionRecord[];
|
||||
}
|
||||
@@ -318,15 +477,25 @@ export async function deleteCollection(
|
||||
id: string
|
||||
): Promise<void> {
|
||||
await ensureAuthenticated(request);
|
||||
const headers = await withCsrfHeaders(request);
|
||||
let headers = await withCsrfHeaders(request);
|
||||
let response = await request.delete(`${API_URL}/collections/${id}`, { headers });
|
||||
|
||||
if (!response.ok() && response.status() === 401) {
|
||||
authenticatedContexts.delete(request);
|
||||
await ensureAuthenticated(request);
|
||||
headers = await withCsrfHeaders(request);
|
||||
response = await request.delete(`${API_URL}/collections/${id}`, { headers });
|
||||
}
|
||||
|
||||
if (!response.ok() && response.status() === 503) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
response = await request.delete(`${API_URL}/collections/${id}`, { headers });
|
||||
}
|
||||
|
||||
if (!response.ok() && response.status() === 403) {
|
||||
await refreshCsrfInfo(request);
|
||||
const retryHeaders = await withCsrfHeaders(request);
|
||||
response = await request.delete(`${API_URL}/collections/${id}`, {
|
||||
headers: retryHeaders,
|
||||
});
|
||||
headers = await withCsrfHeaders(request);
|
||||
response = await request.delete(`${API_URL}/collections/${id}`, { headers });
|
||||
}
|
||||
|
||||
if (!response.ok()) {
|
||||
@@ -335,4 +504,10 @@ export async function deleteCollection(
|
||||
throw new Error(`Failed to delete collection ${id}: ${response.status()} ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await request.get(`${API_URL}/collections`);
|
||||
} catch {
|
||||
// Ignore cache warm-up failures.
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user