feat: implement Phase 2 - Authentication, RLS Middleware, and Route Guards

This commit is contained in:
Luis Gabriel Ramos Robles 2026-06-11 15:02:37 +00:00
parent a2238bd7e3
commit 9ea98f8d76
16 changed files with 1156 additions and 12 deletions

View file

@ -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).
## Phase 2: Authentication & Security Core
* [ ] 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.
* [ ] Build a premium login interface with smooth CSS transition effects.
* [ ] Develop route guards and API middleware verifying user roles (RBAC authorization validation).
* [x] Implement secure JWT session cookie-based auth.
* [x] Develop Prisma transaction middleware binding the active session's `user_id`, `hotel_id`, and `region_id` context to PostgreSQL settings to trigger RLS.
* [x] Build a premium login interface with smooth CSS transition effects.
* [x] Develop route guards and API middleware verifying user roles (RBAC authorization validation).
## Phase 3: Compensation Configuration (Feature 1)
* [ ] Implement UI forms and API endpoints for **Plan Creation** (US-COM-001) with mandatory fields validation.

20
package-lock.json generated
View file

@ -10,6 +10,7 @@
"dependencies": {
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.3",
"forgejo-mcp": "^1.2.0",
"mcp-git": "^0.0.4",
"next": "16.2.9",
@ -18,6 +19,7 @@
"react-dom": "19.2.4"
},
"devDependencies": {
"@types/bcryptjs": "^3.0.0",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
@ -1696,6 +1698,16 @@
"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": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@ -2718,6 +2730,14 @@
"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": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz",

View file

@ -7,12 +7,17 @@
"build": "next build",
"start": "next start",
"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": "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": {
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.3",
"forgejo-mcp": "^1.2.0",
"mcp-git": "^0.0.4",
"next": "16.2.9",
@ -21,6 +26,7 @@
"react-dom": "19.2.4"
},
"devDependencies": {
"@types/bcryptjs": "^3.0.0",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",

View file

@ -22,13 +22,13 @@ ON CONFLICT ("code") DO NOTHING;
-- Seed Users
-- password_hash is bcrypt hash of 'password123'
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()),
('director', 'director@estelar.com', '$2b$10$EpjJNrk.wU5YzB3sJsmJg.35A5EJUPhq31O7p8HwS5zM4pU1fT26G', '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()),
('lider_ctg', 'lider.ctg@estelar.com', '$2b$10$EpjJNrk.wU5YzB3sJsmJg.35A5EJUPhq31O7p8HwS5zM4pU1fT26G', '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()),
('consulta', 'consulta@estelar.com', '$2b$10$EpjJNrk.wU5YzB3sJsmJg.35A5EJUPhq31O7p8HwS5zM4pU1fT26G', '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())
('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$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'DIRECTOR', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Comercial', '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$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'LIDER', (SELECT id FROM hotels WHERE code = 'EST-CTG'), 'Ventas', '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$lRn/GrwzaEGxWqqQGGz0f.44nBywObKkGWRajlxWeDGZ3o4f7ayjy', 'CONSULTA', (SELECT id FROM hotels WHERE code = 'EST-P93'), 'Auditoria', '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;

316
prisma/test-auth-rls.js Normal file
View 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);
});

View 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 }
);
}
}

View 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;
}

View 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 });
}

View 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
});
});

View 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
View 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
View 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
View 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
View 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
View 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
View 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).*)']
};