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

- Implemented multi-user authentication with role-based access control.
- Added environment variables for initial admin user setup.
- Updated README and example environment file with new authentication options.
- Introduced user and system configuration models in the database schema.
- Enhanced authentication middleware to support user registration and role management.
- Updated frontend to handle new authentication flows, including admin user creation and role updates.
This commit is contained in:
Adrian Acala
2026-01-18 09:43:32 -08:00
parent 20ef4ee295
commit 1a52fe80f3
27 changed files with 1692 additions and 237 deletions
+146 -24
View File
@@ -1,34 +1,44 @@
import { APIRequestContext, Page } from "@playwright/test";
import { API_URL, getCsrfHeaders } from "./api";
import { API_URL, getCsrfHeaders, refreshCsrfToken } from "./api";
const BASE_URL = process.env.API_URL || API_URL;
type AuthStatus = {
enabled: boolean;
authenticated: boolean;
bootstrapRequired?: boolean;
};
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin";
const authStatusCache = new WeakMap<APIRequestContext, AuthStatus>();
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || "admin123";
const fetchAuthStatus = async (request: APIRequestContext): Promise<AuthStatus> => {
const cached = authStatusCache.get(request);
// Only use cache if we're already authenticated
if (cached?.authenticated) return cached;
const maxAttempts = 3;
const response = await request.get(`${API_URL}/auth/status`);
if (!response.ok()) {
const text = await response.text();
throw new Error(`Failed to fetch auth status: ${response.status()} ${text}`);
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
const response = await request.get(`${BASE_URL}/auth/status`);
if (response.ok()) {
return (await response.json()) as AuthStatus;
}
const text = await response.text();
if (response.status() === 429 && attempt < maxAttempts - 1) {
await new Promise((resolve) => setTimeout(resolve, 1000 + attempt * 500));
continue;
}
throw new Error(`Failed to fetch auth status: ${response.status()} ${text}`);
} catch (error) {
if (attempt < maxAttempts - 1) {
await new Promise((resolve) => setTimeout(resolve, 500 + attempt * 250));
continue;
}
throw error;
}
}
const data = (await response.json()) as AuthStatus;
authStatusCache.set(request, data);
return data;
};
const setAuthStatus = (request: APIRequestContext, status: AuthStatus) => {
authStatusCache.set(request, status);
throw new Error("Failed to fetch auth status");
};
export const ensureApiAuthenticated = async (request: APIRequestContext) => {
@@ -37,11 +47,44 @@ export const ensureApiAuthenticated = async (request: APIRequestContext) => {
return;
}
const headers = await getCsrfHeaders(request);
const response = await request.post(`${API_URL}/auth/login`, {
if (status.bootstrapRequired) {
let response = await request.post(`${BASE_URL}/auth/bootstrap`, {
headers: {
"Content-Type": "application/json",
...(await getCsrfHeaders(request)),
},
data: {
username: AUTH_USERNAME,
password: AUTH_PASSWORD,
},
});
if (!response.ok() && response.status() === 403) {
await refreshCsrfToken(request);
response = await request.post(`${BASE_URL}/auth/bootstrap`, {
headers: {
"Content-Type": "application/json",
...(await getCsrfHeaders(request)),
},
data: {
username: AUTH_USERNAME,
password: AUTH_PASSWORD,
},
});
}
if (!response.ok()) {
const text = await response.text();
throw new Error(`Failed to bootstrap test session: ${response.status()} ${text}`);
}
return;
}
let response = await request.post(`${BASE_URL}/auth/login`, {
headers: {
"Content-Type": "application/json",
...headers,
...(await getCsrfHeaders(request)),
},
data: {
username: AUTH_USERNAME,
@@ -49,14 +92,93 @@ export const ensureApiAuthenticated = async (request: APIRequestContext) => {
},
});
if (!response.ok() && response.status() === 403) {
await refreshCsrfToken(request);
const freshHeaders = {
"Content-Type": "application/json",
...(await getCsrfHeaders(request)),
};
response = await request.post(`${BASE_URL}/auth/login`, {
headers: freshHeaders,
data: {
username: AUTH_USERNAME,
password: AUTH_PASSWORD,
},
});
}
if (!response.ok()) {
const text = await response.text();
throw new Error(`Failed to authenticate test session: ${response.status()} ${text}`);
}
setAuthStatus(request, { enabled: true, authenticated: true });
};
export const ensurePageAuthenticated = async (page: Page) => {
type EnsureAuthOptions = {
skipNavigation?: boolean;
};
export const ensurePageAuthenticated = async (
page: Page,
{ skipNavigation = false }: EnsureAuthOptions = {}
) => {
await ensureApiAuthenticated(page.request);
const storageState = await page.request.storageState();
if (storageState.cookies.length > 0) {
await page.context().addCookies(
storageState.cookies.filter((cookie) => cookie.name && cookie.value)
);
}
if (!skipNavigation) {
await page.goto("/", { waitUntil: "domcontentloaded" });
}
const dashboardReady = page.getByPlaceholder("Search drawings...");
const identifierField = page.getByLabel("Username or Email");
const passwordField = page.getByLabel("Password");
if (skipNavigation) {
if (await identifierField.isVisible({ timeout: 1000 }).catch(() => false)) {
await identifierField.fill(AUTH_USERNAME);
await passwordField.fill(AUTH_PASSWORD);
const confirmPasswordField = page.getByLabel("Confirm Password");
if (await confirmPasswordField.isVisible().catch(() => false)) {
await confirmPasswordField.fill(AUTH_PASSWORD);
}
await page
.getByRole("button", { name: /sign in|create admin|create account/i })
.click();
await dashboardReady.waitFor({ state: "visible", timeout: 30000 });
}
return;
}
await Promise.race([
dashboardReady.waitFor({ state: "visible", timeout: 15000 }),
identifierField.waitFor({ state: "visible", timeout: 15000 }),
]);
if (await dashboardReady.isVisible().catch(() => false)) {
return;
}
if (await identifierField.isVisible().catch(() => false)) {
await identifierField.fill(AUTH_USERNAME);
await passwordField.fill(AUTH_PASSWORD);
const confirmPasswordField = page.getByLabel("Confirm Password");
if (await confirmPasswordField.isVisible().catch(() => false)) {
await confirmPasswordField.fill(AUTH_PASSWORD);
}
await page
.getByRole("button", { name: /sign in|create admin|create account/i })
.click();
await dashboardReady.waitFor({ state: "visible", timeout: 30000 });
return;
}
await dashboardReady.waitFor({ state: "visible", timeout: 15000 });
};