feat: implement Phase 2 - Authentication, RLS Middleware, and Route Guards
This commit is contained in:
parent
a2238bd7e3
commit
9ea98f8d76
16 changed files with 1156 additions and 12 deletions
|
|
@ -17,10 +17,10 @@ This plan outlines the step-by-step path to construct, test, and host the platfo
|
||||||
* [x] Execute initial database migration to seed basic structural tables (Regions, Hotels, Roles).
|
* [x] Execute initial database migration to seed basic structural tables (Regions, Hotels, Roles).
|
||||||
|
|
||||||
## Phase 2: Authentication & Security Core
|
## Phase 2: Authentication & Security Core
|
||||||
* [ ] Implement secure JWT session cookie-based auth.
|
* [x] Implement secure JWT session cookie-based auth.
|
||||||
* [ ] Develop Prisma transaction middleware binding the active session's `user_id`, `hotel_id`, and `region_id` context to PostgreSQL settings to trigger RLS.
|
* [x] Develop Prisma transaction middleware binding the active session's `user_id`, `hotel_id`, and `region_id` context to PostgreSQL settings to trigger RLS.
|
||||||
* [ ] Build a premium login interface with smooth CSS transition effects.
|
* [x] Build a premium login interface with smooth CSS transition effects.
|
||||||
* [ ] Develop route guards and API middleware verifying user roles (RBAC authorization validation).
|
* [x] Develop route guards and API middleware verifying user roles (RBAC authorization validation).
|
||||||
|
|
||||||
## Phase 3: Compensation Configuration (Feature 1)
|
## Phase 3: Compensation Configuration (Feature 1)
|
||||||
* [ ] Implement UI forms and API endpoints for **Plan Creation** (US-COM-001) with mandatory fields validation.
|
* [ ] Implement UI forms and API endpoints for **Plan Creation** (US-COM-001) with mandatory fields validation.
|
||||||
|
|
|
||||||
20
package-lock.json
generated
20
package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.8.0",
|
"@prisma/adapter-pg": "^7.8.0",
|
||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.8.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"forgejo-mcp": "^1.2.0",
|
"forgejo-mcp": "^1.2.0",
|
||||||
"mcp-git": "^0.0.4",
|
"mcp-git": "^0.0.4",
|
||||||
"next": "16.2.9",
|
"next": "16.2.9",
|
||||||
|
|
@ -18,6 +19,7 @@
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
|
|
@ -1696,6 +1698,16 @@
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/bcryptjs": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==",
|
||||||
|
"deprecated": "This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
|
|
@ -2718,6 +2730,14 @@
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||||
|
"bin": {
|
||||||
|
"bcrypt": "bin/bcrypt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/better-result": {
|
"node_modules/better-result": {
|
||||||
"version": "2.9.2",
|
"version": "2.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,17 @@
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
|
"db:seed": "npx prisma db execute --file prisma/rls_and_seed.sql",
|
||||||
|
"db:seed-test": "DATABASE_URL=\"postgresql://special_hotel_user:SpecialHotel1235%2F%2A-%2B@pg.gaboggamer.online/special_hotel_test?schema=public\" npx prisma db execute --file prisma/rls_and_seed.sql",
|
||||||
"test:rls": "node prisma/test-rls.js",
|
"test:rls": "node prisma/test-rls.js",
|
||||||
"test": "npm run test:rls"
|
"test:auth-rls": "node prisma/test-auth-rls.js",
|
||||||
|
"test:all": "npm run test:rls && npm run test:auth-rls",
|
||||||
|
"test": "npm run test:all"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.8.0",
|
"@prisma/adapter-pg": "^7.8.0",
|
||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.8.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"forgejo-mcp": "^1.2.0",
|
"forgejo-mcp": "^1.2.0",
|
||||||
"mcp-git": "^0.0.4",
|
"mcp-git": "^0.0.4",
|
||||||
"next": "16.2.9",
|
"next": "16.2.9",
|
||||||
|
|
@ -21,6 +26,7 @@
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,13 @@ ON CONFLICT ("code") DO NOTHING;
|
||||||
-- Seed Users
|
-- Seed Users
|
||||||
-- password_hash is bcrypt hash of 'password123'
|
-- password_hash is bcrypt hash of 'password123'
|
||||||
INSERT INTO "users" ("username", "email", "password_hash", "role", "hotel_id", "area", "status", "created_at") VALUES
|
INSERT INTO "users" ("username", "email", "password_hash", "role", "hotel_id", "area", "status", "created_at") VALUES
|
||||||
('admin', 'admin@estelar.com', '$2b$10$EpjJNrk.wU5YzB3sJsmJg.35A5EJUPhq31O7p8HwS5zM4pU1fT26G', 'ADMIN', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Sistemas', 'ACTIVE', NOW()),
|
('admin', 'admin@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'ADMIN', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Sistemas', 'ACTIVE', NOW()),
|
||||||
('director', 'director@estelar.com', '$2b$10$EpjJNrk.wU5YzB3sJsmJg.35A5EJUPhq31O7p8HwS5zM4pU1fT26G', 'DIRECTOR', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Comercial', 'ACTIVE', NOW()),
|
('director', 'director@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'DIRECTOR', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Comercial', 'ACTIVE', NOW()),
|
||||||
('gerente_mde', 'gerente.mde@estelar.com', '$2b$10$EpjJNrk.wU5YzB3sJsmJg.35A5EJUPhq31O7p8HwS5zM4pU1fT26G', 'GERENTE', (SELECT id FROM hotels WHERE code = 'EST-MDE'), 'Administracion', 'ACTIVE', NOW()),
|
('gerente_mde', 'gerente.mde@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'GERENTE', (SELECT id FROM hotels WHERE code = 'EST-MDE'), 'Administracion', 'ACTIVE', NOW()),
|
||||||
('lider_ctg', 'lider.ctg@estelar.com', '$2b$10$EpjJNrk.wU5YzB3sJsmJg.35A5EJUPhq31O7p8HwS5zM4pU1fT26G', 'LIDER', (SELECT id FROM hotels WHERE code = 'EST-CTG'), 'Ventas', 'ACTIVE', NOW()),
|
('lider_ctg', 'lider.ctg@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'LIDER', (SELECT id FROM hotels WHERE code = 'EST-CTG'), 'Ventas', 'ACTIVE', NOW()),
|
||||||
('analista', 'analista@estelar.com', '$2b$10$EpjJNrk.wU5YzB3sJsmJg.35A5EJUPhq31O7p8HwS5zM4pU1fT26G', 'ANALISTA', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Finanzas', 'ACTIVE', NOW()),
|
('analista', 'analista@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'ANALISTA', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Finanzas', 'ACTIVE', NOW()),
|
||||||
('consulta', 'consulta@estelar.com', '$2b$10$EpjJNrk.wU5YzB3sJsmJg.35A5EJUPhq31O7p8HwS5zM4pU1fT26G', 'CONSULTA', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Auditoria', 'ACTIVE', NOW()),
|
('consulta', 'consulta@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'CONSULTA', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Auditoria', 'ACTIVE', NOW()),
|
||||||
('colaborador_mde', 'colaborador.mde@estelar.com', '$2b$10$EpjJNrk.wU5YzB3sJsmJg.35A5EJUPhq31O7p8HwS5zM4pU1fT26G', 'COLABORADOR', (SELECT id FROM hotels WHERE code = 'EST-MDE'), 'Ventas', 'ACTIVE', NOW())
|
('colaborador_mde', 'colaborador.mde@estelar.com', '$2b$10$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'COLABORADOR', (SELECT id FROM hotels WHERE code = 'EST-MDE'), 'Ventas', 'ACTIVE', NOW())
|
||||||
ON CONFLICT ("username") DO NOTHING;
|
ON CONFLICT ("username") DO NOTHING;
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
316
prisma/test-auth-rls.js
Normal file
316
prisma/test-auth-rls.js
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const { PrismaPg } = require('@prisma/adapter-pg');
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const PORT = 3009;
|
||||||
|
const BASE_URL = `http://localhost:${PORT}`;
|
||||||
|
|
||||||
|
let nextProcess;
|
||||||
|
let prisma;
|
||||||
|
let pool;
|
||||||
|
|
||||||
|
function getPrisma() {
|
||||||
|
if (!prisma) {
|
||||||
|
const dbUrl = new URL(process.env.DATABASE_URL);
|
||||||
|
pool = new Pool({
|
||||||
|
host: dbUrl.hostname,
|
||||||
|
port: dbUrl.port ? parseInt(dbUrl.port) : 5432,
|
||||||
|
user: decodeURIComponent(dbUrl.username),
|
||||||
|
password: decodeURIComponent(dbUrl.password),
|
||||||
|
database: dbUrl.pathname.substring(1).split('?')[0],
|
||||||
|
ssl: false
|
||||||
|
});
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
prisma = new PrismaClient({ adapter });
|
||||||
|
}
|
||||||
|
return prisma;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to run query with admin context
|
||||||
|
async function runAsAdmin(queryFn) {
|
||||||
|
const db = getPrisma();
|
||||||
|
return db.$transaction(async (tx) => {
|
||||||
|
await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'ADMIN';`);
|
||||||
|
return queryFn(tx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (condition) {
|
||||||
|
console.log(` ✓ PASS: ${message}`);
|
||||||
|
passed++;
|
||||||
|
} else {
|
||||||
|
console.error(` ✗ FAIL: ${message}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTests() {
|
||||||
|
console.log("=== STARTING PHASE 2 AUTH & RLS INTEGRATION TEST SUITE ===");
|
||||||
|
|
||||||
|
const db = getPrisma();
|
||||||
|
|
||||||
|
// 1. Seed test sales data as Admin for the test queries
|
||||||
|
console.log("\nSeeding test sales results...");
|
||||||
|
const users = await runAsAdmin(tx => tx.user.findMany());
|
||||||
|
const colaboradorMde = users.find(u => u.username === 'colaborador_mde');
|
||||||
|
const gerenteMde = users.find(u => u.username === 'gerente_mde');
|
||||||
|
const liderCtg = users.find(u => u.username === 'lider_ctg');
|
||||||
|
const adminUser = users.find(u => u.username === 'admin');
|
||||||
|
|
||||||
|
await runAsAdmin(async (tx) => {
|
||||||
|
await tx.salesResult.deleteMany();
|
||||||
|
await tx.auditLog.deleteMany();
|
||||||
|
|
||||||
|
// Colaborador sale
|
||||||
|
await tx.salesResult.create({
|
||||||
|
data: {
|
||||||
|
source: 'EXCEL',
|
||||||
|
hotelId: colaboradorMde.hotelId,
|
||||||
|
userId: colaboradorMde.id,
|
||||||
|
period: '2026-06',
|
||||||
|
amount: 3000.00,
|
||||||
|
salesCount: 5,
|
||||||
|
idempotencyKey: 'auth-test-colab',
|
||||||
|
uploadedBy: adminUser.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lider sale
|
||||||
|
await tx.salesResult.create({
|
||||||
|
data: {
|
||||||
|
source: 'EXCEL',
|
||||||
|
hotelId: liderCtg.hotelId,
|
||||||
|
userId: liderCtg.id,
|
||||||
|
period: '2026-06',
|
||||||
|
amount: 6000.00,
|
||||||
|
salesCount: 8,
|
||||||
|
idempotencyKey: 'auth-test-lider',
|
||||||
|
uploadedBy: adminUser.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Start Next.js server
|
||||||
|
console.log(`\nStarting Next.js dev server on port ${PORT}...`);
|
||||||
|
nextProcess = spawn('npx', ['next', 'dev', '--port', String(PORT)], {
|
||||||
|
shell: true,
|
||||||
|
env: { ...process.env, PORT: String(PORT) }
|
||||||
|
});
|
||||||
|
nextProcess.stdout.on('data', (data) => console.log(`[Next.js] ${data.toString().trim()}`));
|
||||||
|
nextProcess.stderr.on('data', (data) => console.error(`[Next.js ERR] ${data.toString().trim()}`));
|
||||||
|
|
||||||
|
// Wait for server to start up
|
||||||
|
let serverReady = false;
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
await sleep(2000);
|
||||||
|
try {
|
||||||
|
await fetch(`${BASE_URL}/api/auth/me`);
|
||||||
|
serverReady = true;
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
// Server not ready yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!serverReady) {
|
||||||
|
console.error("Error: Next.js dev server failed to start within timeout.");
|
||||||
|
cleanup();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log("Next.js dev server is ready!");
|
||||||
|
|
||||||
|
// --- TEST A: Unauthenticated me query ---
|
||||||
|
console.log("\nRunning TEST A: Unauthenticated /api/auth/me...");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE_URL}/api/auth/me`);
|
||||||
|
assert(res.status === 401, "GET /api/auth/me returns 401 Unauthorized when no cookie is set");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Test A error:", err);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TEST B: Authentication with Invalid Credentials ---
|
||||||
|
console.log("\nRunning TEST B: Authentication with Invalid Credentials...");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE_URL}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: 'colaborador_mde', password: 'wrongpassword' })
|
||||||
|
});
|
||||||
|
assert(res.status === 401, "POST /api/auth/login returns 401 for incorrect password");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Test B error:", err);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TEST C: Authenticate Colaborador and verify RLS ---
|
||||||
|
console.log("\nRunning TEST C: Authenticate Colaborador & Verify RLS...");
|
||||||
|
try {
|
||||||
|
// 1. Login
|
||||||
|
const loginRes = await fetch(`${BASE_URL}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: 'colaborador_mde', password: 'password123' })
|
||||||
|
});
|
||||||
|
if (loginRes.status !== 200) {
|
||||||
|
console.error("Login failed response:", loginRes.status, await loginRes.text());
|
||||||
|
}
|
||||||
|
assert(loginRes.status === 200, "POST /api/auth/login returns 200 OK for valid Colaborador credentials");
|
||||||
|
|
||||||
|
// Save session cookie
|
||||||
|
const setCookie = loginRes.headers.get('set-cookie');
|
||||||
|
assert(setCookie !== null && setCookie.includes('session='), "Response contains 'session' cookie");
|
||||||
|
const cookie = setCookie ? setCookie.split(';')[0] : '';
|
||||||
|
|
||||||
|
// 2. Query /api/auth/me
|
||||||
|
const meRes = await fetch(`${BASE_URL}/api/auth/me`, {
|
||||||
|
headers: { Cookie: cookie }
|
||||||
|
});
|
||||||
|
const meData = await meRes.json();
|
||||||
|
if (meRes.status !== 200) {
|
||||||
|
console.error("GET /api/auth/me failed status:", meRes.status, "body:", meData);
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
meRes.status === 200 && meData.user && meData.user.username === 'colaborador_mde',
|
||||||
|
"GET /api/auth/me returns user details for active session"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Query /api/test-auth (verify RLS)
|
||||||
|
const testAuthRes = await fetch(`${BASE_URL}/api/test-auth`, {
|
||||||
|
headers: { Cookie: cookie }
|
||||||
|
});
|
||||||
|
const testAuthData = await testAuthRes.json();
|
||||||
|
|
||||||
|
assert(
|
||||||
|
testAuthData.users.length === 1 && testAuthData.users[0].username === 'colaborador_mde',
|
||||||
|
"RLS restriction: Colaborador only sees their own user record in queries"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
testAuthData.sales.length === 1 && testAuthData.sales[0].userId === colaboradorMde.id,
|
||||||
|
"RLS restriction: Colaborador only sees their own sales results in queries"
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Test C error:", err);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TEST D: Authenticate Gerente and verify RLS ---
|
||||||
|
console.log("\nRunning TEST D: Authenticate Gerente & Verify RLS...");
|
||||||
|
try {
|
||||||
|
// 1. Login
|
||||||
|
const loginRes = await fetch(`${BASE_URL}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: 'gerente_mde', password: 'password123' })
|
||||||
|
});
|
||||||
|
const setCookie = loginRes.headers.get('set-cookie');
|
||||||
|
const cookie = setCookie ? setCookie.split(';')[0] : '';
|
||||||
|
|
||||||
|
// 2. Query /api/test-auth (verify RLS)
|
||||||
|
const testAuthRes = await fetch(`${BASE_URL}/api/test-auth`, {
|
||||||
|
headers: { Cookie: cookie }
|
||||||
|
});
|
||||||
|
const testAuthData = await testAuthRes.json();
|
||||||
|
|
||||||
|
const mdeUsers = users.filter(u => u.hotelId === gerenteMde.hotelId);
|
||||||
|
const visibleToGerente = testAuthData.users.every(u => u.hotelId === gerenteMde.hotelId);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
testAuthData.users.length === mdeUsers.length && visibleToGerente,
|
||||||
|
`RLS restriction: Gerente only sees users within their hotel (EST-MDE, count: ${mdeUsers.length})`
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleSales = testAuthData.sales.every(s => s.hotelId === gerenteMde.hotelId);
|
||||||
|
assert(
|
||||||
|
testAuthData.sales.length === 1 && visibleSales,
|
||||||
|
"RLS restriction: Gerente only sees sales results within their hotel"
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Test D error:", err);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TEST E: Authenticate Admin and verify RLS bypass ---
|
||||||
|
console.log("\nRunning TEST E: Authenticate Admin & Verify RLS Bypass...");
|
||||||
|
try {
|
||||||
|
// 1. Login
|
||||||
|
const loginRes = await fetch(`${BASE_URL}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: 'admin', password: 'password123' })
|
||||||
|
});
|
||||||
|
const setCookie = loginRes.headers.get('set-cookie');
|
||||||
|
const cookie = setCookie ? setCookie.split(';')[0] : '';
|
||||||
|
|
||||||
|
// 2. Query /api/test-auth (verify RLS)
|
||||||
|
const testAuthRes = await fetch(`${BASE_URL}/api/test-auth`, {
|
||||||
|
headers: { Cookie: cookie }
|
||||||
|
});
|
||||||
|
const testAuthData = await testAuthRes.json();
|
||||||
|
|
||||||
|
assert(
|
||||||
|
testAuthData.users.length === users.length,
|
||||||
|
"RLS bypass: Admin sees all users in the system"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
testAuthData.sales.length === 2,
|
||||||
|
"RLS bypass: Admin sees all sales results in the system"
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Test E error:", err);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
console.log(`\n=== TEST RESULTS: ${passed} PASSED, ${failed} FAILED ===`);
|
||||||
|
if (failed > 0) {
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
console.log("\nCleaning up test records and shutting down servers...");
|
||||||
|
try {
|
||||||
|
if (prisma) {
|
||||||
|
await runAsAdmin(async (tx) => {
|
||||||
|
await tx.salesResult.deleteMany();
|
||||||
|
await tx.auditLog.deleteMany();
|
||||||
|
});
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
if (pool) {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error during DB cleanup:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextProcess) {
|
||||||
|
nextProcess.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle exit gracefully
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
cleanup();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
runTests().catch(err => {
|
||||||
|
console.error("Fatal test runner error:", err);
|
||||||
|
cleanup();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
100
src/app/api/auth/login/route.ts
Normal file
100
src/app/api/auth/login/route.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getPrisma } from '@/lib/db';
|
||||||
|
import { comparePassword, signJwt } from '@/lib/auth';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { username, password } = await req.json();
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Username and password are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run lookup as ADMIN to bypass RLS since the user is not authenticated yet
|
||||||
|
const prismaAdmin = getPrisma({
|
||||||
|
userId: 0,
|
||||||
|
username: 'login_system',
|
||||||
|
email: 'system@estelar.com',
|
||||||
|
role: 'ADMIN',
|
||||||
|
hotelId: 0,
|
||||||
|
regionId: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = await prismaAdmin.user.findUnique({
|
||||||
|
where: { username },
|
||||||
|
include: { hotel: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || user.status !== 'ACTIVE') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid credentials or inactive user' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordMatch = await comparePassword(password, user.passwordHash);
|
||||||
|
if (!passwordMatch) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid credentials' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session payload
|
||||||
|
const sessionPayload = {
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
hotelId: user.hotelId,
|
||||||
|
regionId: user.hotel.regionId
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sign JWT
|
||||||
|
const token = signJwt(sessionPayload);
|
||||||
|
|
||||||
|
// Set cookie
|
||||||
|
const response = NextResponse.json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
hotelId: user.hotelId,
|
||||||
|
area: user.area
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
response.cookies.set({
|
||||||
|
name: 'session',
|
||||||
|
value: token,
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 86400 // 1 day
|
||||||
|
});
|
||||||
|
|
||||||
|
// Record login audit log
|
||||||
|
await prismaAdmin.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
action: 'LOGIN',
|
||||||
|
targetTable: 'users',
|
||||||
|
targetId: user.id,
|
||||||
|
ipAddress: req.headers.get('x-forwarded-for') || '127.0.0.1'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Login error:', err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/app/api/auth/logout/route.ts
Normal file
16
src/app/api/auth/logout/route.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const response = NextResponse.json({ success: true });
|
||||||
|
|
||||||
|
// Clear cookie
|
||||||
|
response.cookies.set({
|
||||||
|
name: 'session',
|
||||||
|
value: '',
|
||||||
|
httpOnly: true,
|
||||||
|
expires: new Date(0),
|
||||||
|
path: '/'
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
15
src/app/api/auth/me/route.ts
Normal file
15
src/app/api/auth/me/route.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getSession } from '@/lib/auth';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const session = getSession(req);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ user: session });
|
||||||
|
}
|
||||||
33
src/app/api/test-auth/route.ts
Normal file
33
src/app/api/test-auth/route.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { withAuth } from '@/lib/api-guards';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test API route to verify JWT authentication, RBAC, and RLS behavior.
|
||||||
|
*/
|
||||||
|
export const GET = withAuth(async (req, { session, prisma }) => {
|
||||||
|
// Fetch users and salesResults using the context-bound client
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
role: true,
|
||||||
|
hotelId: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sales = await prisma.salesResult.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
hotelId: true,
|
||||||
|
period: true,
|
||||||
|
amount: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
session,
|
||||||
|
users,
|
||||||
|
sales
|
||||||
|
});
|
||||||
|
});
|
||||||
199
src/app/login/page.module.css
Normal file
199
src/app/login/page.module.css
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: radial-gradient(circle at top right, hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.12), transparent 45%),
|
||||||
|
radial-gradient(circle at bottom left, hsla(var(--secondary-h), var(--secondary-s), var(--secondary-l), 0.1), transparent 40%),
|
||||||
|
var(--background);
|
||||||
|
transition: background var(--transition-slow);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--primary);
|
||||||
|
filter: blur(120px);
|
||||||
|
opacity: 0.15;
|
||||||
|
top: 15%;
|
||||||
|
right: 15%;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: pulse 8s infinite alternate ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { transform: scale(1) translate(0, 0); opacity: 0.12; }
|
||||||
|
100% { transform: scale(1.2) translate(-20px, 20px); opacity: 0.18; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
padding: var(--space-8) var(--space-6);
|
||||||
|
background-color: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--shadow-lg), var(--shadow-glow);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
transition: transform var(--transition-normal), box-shadow var(--transition-normal);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.card {
|
||||||
|
padding: var(--space-12) var(--space-8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-lg), 0 0 25px 4px hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--weight-bold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.075em;
|
||||||
|
color: var(--primary);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
font-weight: var(--weight-bold);
|
||||||
|
color: var(--foreground);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
color: var(--foreground);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputWrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
color: var(--foreground);
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: hsl(0, 85%, 60%);
|
||||||
|
background-color: hsl(0, 85%, 97%);
|
||||||
|
border: 1px solid hsl(0, 85%, 90%);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
animation: shake 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] .error {
|
||||||
|
background-color: hsla(0, 85%, 20%, 0.15);
|
||||||
|
border-color: hsla(0, 85%, 50%, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
25% { transform: translateX(-4px); }
|
||||||
|
75% { transform: translateX(4px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-3);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
color: #ffffff;
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: transform var(--transition-fast), box-shadow var(--transition-fast), filter var(--transition-fast);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px hsla(var(--primary-h), var(--primary-s), var(--primary-l), 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: #ffffff;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: var(--space-6);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--foreground);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
118
src/app/login/page.tsx
Normal file
118
src/app/login/page.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
'use strict';
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import styles from './page.module.css';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Read return url if any
|
||||||
|
const callbackUrl = searchParams.get('callbackUrl') || '/';
|
||||||
|
|
||||||
|
// Clear session on component mount just in case
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error || 'Login failed. Please check your credentials.');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to target dashboard
|
||||||
|
router.push(callbackUrl);
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError('An unexpected error occurred. Please try again.');
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.subtitle}>Hoteles Estelar</div>
|
||||||
|
<h1 className={styles.title}>Iniciar Sesión</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className={styles.form}>
|
||||||
|
{error && (
|
||||||
|
<div className={styles.error} role="alert">
|
||||||
|
<span>⚠️</span>
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.group}>
|
||||||
|
<label className={styles.label} htmlFor="username">
|
||||||
|
Usuario
|
||||||
|
</label>
|
||||||
|
<div className={styles.inputWrapper}>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className={styles.input}
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Ingrese su usuario"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.group}>
|
||||||
|
<label className={styles.label} htmlFor="password">
|
||||||
|
Contraseña
|
||||||
|
</label>
|
||||||
|
<div className={styles.inputWrapper}>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
className={styles.input}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Ingrese su contraseña"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className={styles.button} disabled={isLoading}>
|
||||||
|
{isLoading ? <div className={styles.spinner} /> : 'Ingresar'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className={styles.footer}>
|
||||||
|
Sistema de Remuneración Variable y Comisiones
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/lib/api-guards.ts
Normal file
48
src/lib/api-guards.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getSession, UserSession } from './auth';
|
||||||
|
import { getPrisma } from './db';
|
||||||
|
|
||||||
|
type AuthenticatedHandler = (
|
||||||
|
req: NextRequest,
|
||||||
|
context: { session: UserSession; prisma: any; params?: any }
|
||||||
|
) => Promise<NextResponse> | NextResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Route guard that enforces authentication and RBAC role validation.
|
||||||
|
* Binds the authenticated session to the context-aware Prisma client.
|
||||||
|
*/
|
||||||
|
export function withAuth(
|
||||||
|
handler: AuthenticatedHandler,
|
||||||
|
allowedRoles?: string[]
|
||||||
|
) {
|
||||||
|
return async (req: NextRequest, { params }: { params?: any } = {}) => {
|
||||||
|
try {
|
||||||
|
const session = getSession(req);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized. Session expired or missing.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowedRoles && allowedRoles.length > 0 && !allowedRoles.includes(session.role)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Forbidden. Insufficient permissions.' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get context-aware Prisma client bound to active session
|
||||||
|
const prisma = getPrisma(session);
|
||||||
|
|
||||||
|
return await handler(req, { session, prisma, params });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('API guard execution error:', err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
75
src/lib/auth-edge.ts
Normal file
75
src/lib/auth-edge.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
export interface UserSession {
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
hotelId: number;
|
||||||
|
regionId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.NEXTAUTH_SECRET || 'fallback_secret_for_development_jwt_auth';
|
||||||
|
|
||||||
|
function base64urlDecode(str: string): string {
|
||||||
|
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
while (base64.length % 4) {
|
||||||
|
base64 += '=';
|
||||||
|
}
|
||||||
|
return atob(base64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a JWT natively in the Edge Runtime using Web Crypto API.
|
||||||
|
*/
|
||||||
|
export async function verifyJwtEdge(token: string): Promise<UserSession | null> {
|
||||||
|
try {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
|
||||||
|
const [headerB64, payloadB64, signatureB64] = parts;
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const keyData = encoder.encode(JWT_SECRET);
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
keyData,
|
||||||
|
{ name: 'HMAC', hash: { name: 'SHA-256' } },
|
||||||
|
false,
|
||||||
|
['verify']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Decode base64url signature to binary array
|
||||||
|
let base64Sig = signatureB64.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
while (base64Sig.length % 4) {
|
||||||
|
base64Sig += '=';
|
||||||
|
}
|
||||||
|
|
||||||
|
const signatureBin = atob(base64Sig);
|
||||||
|
const signatureBuf = new Uint8Array(signatureBin.length);
|
||||||
|
for (let i = 0; i < signatureBin.length; i++) {
|
||||||
|
signatureBuf[i] = signatureBin.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataBuf = encoder.encode(`${headerB64}.${payloadB64}`);
|
||||||
|
const isValid = await crypto.subtle.verify(
|
||||||
|
'HMAC',
|
||||||
|
key,
|
||||||
|
signatureBuf,
|
||||||
|
dataBuf
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValid) return null;
|
||||||
|
|
||||||
|
const payload = JSON.parse(base64urlDecode(payloadB64));
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if (payload.exp && Date.now() / 1000 > payload.exp) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as UserSession;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Edge JWT verification error:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/lib/auth.ts
Normal file
81
src/lib/auth.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.NEXTAUTH_SECRET || 'fallback_secret_for_development_jwt_auth';
|
||||||
|
|
||||||
|
export interface UserSession {
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
hotelId: number;
|
||||||
|
regionId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base64URL encoding helpers
|
||||||
|
function base64urlEncode(str: string): string {
|
||||||
|
return Buffer.from(str).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64urlDecode(str: string): string {
|
||||||
|
return Buffer.from(str, 'base64url').toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign JWT natively
|
||||||
|
export function signJwt(payload: object, expiresInSeconds = 86400): string {
|
||||||
|
const header = { alg: 'HS256', typ: 'JWT' };
|
||||||
|
const exp = Math.floor(Date.now() / 1000) + expiresInSeconds;
|
||||||
|
|
||||||
|
const fullPayload = { ...payload, exp };
|
||||||
|
|
||||||
|
const encodedHeader = base64urlEncode(JSON.stringify(header));
|
||||||
|
const encodedPayload = base64urlEncode(JSON.stringify(fullPayload));
|
||||||
|
|
||||||
|
const signature = crypto
|
||||||
|
.createHmac('sha256', JWT_SECRET)
|
||||||
|
.update(`${encodedHeader}.${encodedPayload}`)
|
||||||
|
.digest('base64url');
|
||||||
|
|
||||||
|
return `${encodedHeader}.${encodedPayload}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify JWT natively
|
||||||
|
export function verifyJwt(token: string): UserSession | null {
|
||||||
|
try {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
|
||||||
|
const [headerB64, payloadB64, signature] = parts;
|
||||||
|
|
||||||
|
const expectedSignature = crypto
|
||||||
|
.createHmac('sha256', JWT_SECRET)
|
||||||
|
.update(`${headerB64}.${payloadB64}`)
|
||||||
|
.digest('base64url');
|
||||||
|
|
||||||
|
if (signature !== expectedSignature) return null;
|
||||||
|
|
||||||
|
const payload = JSON.parse(base64urlDecode(payloadB64));
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if (payload.exp && Date.now() / 1000 > payload.exp) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as UserSession;
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare password hash
|
||||||
|
export async function comparePassword(password: string, hash: string): Promise<boolean> {
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get session from request cookies
|
||||||
|
export function getSession(req: NextRequest): UserSession | null {
|
||||||
|
const cookie = req.cookies.get('session');
|
||||||
|
if (!cookie?.value) return null;
|
||||||
|
return verifyJwt(cookie.value);
|
||||||
|
}
|
||||||
51
src/lib/db.ts
Normal file
51
src/lib/db.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { UserSession } from './auth';
|
||||||
|
|
||||||
|
let pool: Pool;
|
||||||
|
let globalPrisma: PrismaClient;
|
||||||
|
|
||||||
|
const getPrismaClient = (): PrismaClient => {
|
||||||
|
if (!globalPrisma) {
|
||||||
|
const dbUrl = new URL(process.env.DATABASE_URL!);
|
||||||
|
pool = new Pool({
|
||||||
|
host: dbUrl.hostname,
|
||||||
|
port: dbUrl.port ? parseInt(dbUrl.port) : 5432,
|
||||||
|
user: decodeURIComponent(dbUrl.username),
|
||||||
|
password: decodeURIComponent(dbUrl.password),
|
||||||
|
database: dbUrl.pathname.substring(1).split('?')[0],
|
||||||
|
ssl: false
|
||||||
|
});
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
globalPrisma = new PrismaClient({ adapter });
|
||||||
|
}
|
||||||
|
return globalPrisma;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPrisma = (session?: UserSession | null) => {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return prisma;
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.$extends({
|
||||||
|
query: {
|
||||||
|
$allModels: {
|
||||||
|
async $allOperations({ args, query }) {
|
||||||
|
// Execute RLS parameter setting followed by the original query in a batch transaction
|
||||||
|
const results = await prisma.$transaction([
|
||||||
|
prisma.$executeRawUnsafe(`SET LOCAL app.current_user_id = '${session.userId}';`),
|
||||||
|
prisma.$executeRawUnsafe(`SET LOCAL app.current_user_role = '${session.role}';`),
|
||||||
|
prisma.$executeRawUnsafe(`SET LOCAL app.current_hotel_id = '${session.hotelId}';`),
|
||||||
|
prisma.$executeRawUnsafe(`SET LOCAL app.current_region_id = '${session.regionId}';`),
|
||||||
|
query(args)
|
||||||
|
]);
|
||||||
|
// Return the results of the query block
|
||||||
|
return results[4];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
66
src/middleware.ts
Normal file
66
src/middleware.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { verifyJwtEdge } from '@/lib/auth-edge';
|
||||||
|
|
||||||
|
export async function middleware(req: NextRequest) {
|
||||||
|
const { pathname } = req.nextUrl;
|
||||||
|
|
||||||
|
// Bypass auth checks for login API and public/assets routes
|
||||||
|
if (
|
||||||
|
pathname.startsWith('/api/auth/login') ||
|
||||||
|
pathname.startsWith('/api/auth/logout') ||
|
||||||
|
pathname.startsWith('/_next') ||
|
||||||
|
pathname.endsWith('.ico') ||
|
||||||
|
pathname.endsWith('.svg') ||
|
||||||
|
pathname.endsWith('.png') ||
|
||||||
|
pathname.endsWith('.jpg')
|
||||||
|
) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get session cookie
|
||||||
|
const allCookies = req.cookies.getAll();
|
||||||
|
const sessionCookie = req.cookies.get('session')?.value;
|
||||||
|
console.log(`[Middleware] Path: ${pathname}, Cookies received:`, allCookies.map(c => c.name), "Session value exists:", !!sessionCookie);
|
||||||
|
const session = sessionCookie ? await verifyJwtEdge(sessionCookie) : null;
|
||||||
|
|
||||||
|
// If on login page
|
||||||
|
if (pathname === '/login') {
|
||||||
|
if (session) {
|
||||||
|
// User is already logged in, redirect to dashboard (home page)
|
||||||
|
return NextResponse.redirect(new URL('/', req.url));
|
||||||
|
}
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If trying to access any other route and not logged in
|
||||||
|
if (!session) {
|
||||||
|
if (pathname.startsWith('/api/')) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized. Session expired or missing.' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Redirect to login page
|
||||||
|
const loginUrl = new URL('/login', req.url);
|
||||||
|
// Optional: save return URL
|
||||||
|
loginUrl.searchParams.set('callbackUrl', pathname);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If accessing API routes, we can inject role headers or simply allow Next.js route guards to handle it
|
||||||
|
const response = NextResponse.next();
|
||||||
|
|
||||||
|
// Forward session details in request headers to simplify API role checking if needed
|
||||||
|
response.headers.set('x-user-id', String(session.userId));
|
||||||
|
response.headers.set('x-user-role', session.role);
|
||||||
|
response.headers.set('x-user-hotel-id', String(session.hotelId));
|
||||||
|
response.headers.set('x-user-region-id', String(session.regionId));
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// Apply middleware to all routes except api routes that are not auth related (we will handle api auth directly in routes)
|
||||||
|
matcher: ['/((?!api/n8n|api/sales/batch-save).*)']
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue