feat(identity): redesign identity module and introduce RBAC foundation

- Redesign the Identity module with a richer domain model.
- Extend the User entity to support username, Authentik integration, activity tracking, and storage information.
- Add Role and Permission domain models with many-to-many relationships.
- Implement RBAC foundation using UserRole, RolePermission, and UserPermission mappings.
- Add user storage quota and usage fields with default values.
- Introduce Authentik identifiers and synchronization metadata.
- Refactor user domain logic for role and permission management.
- Update Prisma schema to support the new identity architecture.
- Improve JWT authentication and permission guard integration.
- Update repositories, handlers, controllers, mappers, DTOs, and Swagger configuration.
- Refresh environment configuration and project dependencies.
This commit is contained in:
Rayyan
2026-08-02 00:25:36 +07:00
parent fdbfb34842
commit 7ce0de4e91
132 changed files with 7754 additions and 2037 deletions
+91
View File
@@ -0,0 +1,91 @@
import { chromium } from 'playwright';
import { env } from './env';
const store: Map<string, { token: string; expiresAt?: number }> = new Map();
function setToken(key: string, token: string, expiresIn?: number) {
const expiresAt = expiresIn ? Date.now() + expiresIn * 1000 - 5000 : undefined;
store.set(key, { token, expiresAt });
}
function getTokenFromStore(key: string) {
const entry = store.get(key);
if (!entry) return null;
if (entry.expiresAt && Date.now() > entry.expiresAt) {
store.delete(key);
return null;
}
return entry.token;
}
async function loginViaRaylab(username: string, password: string) {
const base = env.BASE_URL || 'http://localhost:3000';
const browser = await chromium.launch({ headless: Boolean(process.env.PW_HEADLESS || '1') });
const context = await browser.newContext();
const page = await context.newPage();
// Navigate to RayLab - expects redirect to identity provider
await page.goto(base, { waitUntil: 'networkidle' });
// Wait for a login form to appear on identity provider
try {
await page.waitForSelector('input[type="text"], input[type="email"], input[name="username"]', { timeout: 10000 });
// fill username
const usernameInput = await page.$('input[name="username"]') || await page.$('input[type="email"]') || await page.$('input[type="text"]');
if (usernameInput) await usernameInput.fill(username);
// fill password
const passwordInput = await page.$('input[type="password"], input[name="password"]');
if (passwordInput) await passwordInput.fill(password);
// try to submit
const submitButton = await page.$('button[type="submit"], input[type="submit"]');
if (submitButton) {
await Promise.all([page.waitForNavigation({ waitUntil: 'networkidle', timeout: 20000 }), submitButton.click()]);
}
} catch (e) {
// If no login form found, we may already be at callback
}
// Wait for callback to be done and cookie to be set
await page.waitForTimeout(1000);
const cookies = await context.cookies();
const jwtCookie = cookies.find(c => c.name === 'raylab_jwt');
const refreshCookie = cookies.find(c => c.name === 'raylab_refresh');
const result: any = {};
if (jwtCookie) result.accessToken = jwtCookie.value;
if (refreshCookie) result.refreshToken = refreshCookie.value;
await context.close();
await browser.close();
if (!result.accessToken) throw new Error('Login failed: no session cookie set');
return result;
}
export async function getToken(role: 'owner' | 'admin' | 'employee' = 'admin') {
const cacheKey = `role:${role}`;
const cached = getTokenFromStore(cacheKey);
if (cached) return cached;
const username = role === 'owner' ? env.OWNER_USERNAME : role === 'admin' ? env.ADMIN_USERNAME : env.EMPLOYEE_USERNAME;
const password = role === 'owner' ? env.OWNER_PASSWORD : role === 'admin' ? env.ADMIN_PASSWORD : env.EMPLOYEE_PASSWORD;
if (!username || !password) throw new Error('Missing credentials in .env for role: ' + role);
const data = await loginViaRaylab(username, password);
const token = data.accessToken;
// expiresIn not available from cookie - default to 1 hour
const expiresIn = Number(process.env.TEST_TOKEN_EXPIRES_IN || 3600);
setToken(cacheKey, token, expiresIn);
return token;
}
export async function getRawToken(username: string, password: string) {
const data = await loginViaRaylab(username, password);
return data.accessToken;
}
export default { getToken, getRawToken };
+7
View File
@@ -0,0 +1,7 @@
import dotenv from 'dotenv';
import path from 'path';
const envPath = path.resolve(process.cwd(), '.env');
dotenv.config({ path: envPath });
export const env = process.env;
+3
View File
@@ -0,0 +1,3 @@
export * from './auth';
export * from './request';
export * from './env';
+73
View File
@@ -0,0 +1,73 @@
import { request } from '@playwright/test';
import { env } from './env';
import { getToken } from './auth';
async function buildContext(token?: string) {
const headers: any = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const api = await request.newContext({ baseURL: env.BASE_URL || 'http://localhost:3000', extraHTTPHeaders: headers });
return api;
}
export async function requestGet(path: string, role: 'owner' | 'admin' | 'employee' | null = 'admin', opts: any = {}) {
const token = role ? await getToken(role) : undefined;
const api = await buildContext(token);
try {
const res = await api.get(path, opts);
const body = await parseResponseSafe(res);
return { status: res.status(), body, headers: res.headers() };
} finally {
await api.dispose();
}
}
export async function requestPost(path: string, role: 'owner' | 'admin' | 'employee' | null = 'admin', data?: any, opts: any = {}) {
const token = role ? await getToken(role) : undefined;
const api = await buildContext(token);
try {
const res = await api.post(path, { data, ...opts });
const body = await parseResponseSafe(res);
return { status: res.status(), body, headers: res.headers() };
} finally {
await api.dispose();
}
}
export async function requestPatch(path: string, role: 'owner' | 'admin' | 'employee' | null = 'admin', data?: any, opts: any = {}) {
const token = role ? await getToken(role) : undefined;
const api = await buildContext(token);
try {
const res = await api.patch(path, { data, ...opts });
const body = await parseResponseSafe(res);
return { status: res.status(), body, headers: res.headers() };
} finally {
await api.dispose();
}
}
export async function requestDelete(path: string, role: 'owner' | 'admin' | 'employee' | null = 'admin', opts: any = {}) {
const token = role ? await getToken(role) : undefined;
const api = await buildContext(token);
try {
const res = await api.delete(path, opts);
const body = await parseResponseSafe(res);
return { status: res.status(), body, headers: res.headers() };
} finally {
await api.dispose();
}
}
async function parseResponseSafe(res: any) {
const ct = res.headers()['content-type'] || '';
try {
if (ct.includes('application/json')) return await res.json();
return await res.text();
} catch (e) {
try {
return await res.text();
} catch (e2) {
return null;
}
}
}