diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index cfc83f0..7c964cd 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 4671cda..504ae13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7c63e79..4484092 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/rls_and_seed.sql b/prisma/rls_and_seed.sql index a0cff74..3ce4a78 100644 --- a/prisma/rls_and_seed.sql +++ b/prisma/rls_and_seed.sql @@ -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; diff --git a/prisma/test-auth-rls.js b/prisma/test-auth-rls.js new file mode 100644 index 0000000..3a210c8 --- /dev/null +++ b/prisma/test-auth-rls.js @@ -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); +}); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..9ea5972 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..0675f36 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -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; +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..bcd1cbb --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -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 }); +} diff --git a/src/app/api/test-auth/route.ts b/src/app/api/test-auth/route.ts new file mode 100644 index 0000000..53df363 --- /dev/null +++ b/src/app/api/test-auth/route.ts @@ -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 + }); +}); diff --git a/src/app/login/page.module.css b/src/app/login/page.module.css new file mode 100644 index 0000000..ab9bf3b --- /dev/null +++ b/src/app/login/page.module.css @@ -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; +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..adc9e3d --- /dev/null +++ b/src/app/login/page.tsx @@ -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(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 ( +
+
+
+
Hoteles Estelar
+

Iniciar Sesión

+
+ +
+ {error && ( +
+ ⚠️ + {error} +
+ )} + +
+ +
+ setUsername(e.target.value)} + placeholder="Ingrese su usuario" + disabled={isLoading} + /> +
+
+ +
+ +
+ setPassword(e.target.value)} + placeholder="Ingrese su contraseña" + disabled={isLoading} + /> +
+
+ + +
+ +
+ Sistema de Remuneración Variable y Comisiones +
+
+
+ ); +} diff --git a/src/lib/api-guards.ts b/src/lib/api-guards.ts new file mode 100644 index 0000000..85cc11c --- /dev/null +++ b/src/lib/api-guards.ts @@ -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; + +/** + * 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 } + ); + } + }; +} diff --git a/src/lib/auth-edge.ts b/src/lib/auth-edge.ts new file mode 100644 index 0000000..be53ac3 --- /dev/null +++ b/src/lib/auth-edge.ts @@ -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 { + 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; + } +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..63d268b --- /dev/null +++ b/src/lib/auth.ts @@ -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 { + 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); +} diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..0138247 --- /dev/null +++ b/src/lib/db.ts @@ -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]; + } + } + } + }); +}; diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..b87138b --- /dev/null +++ b/src/middleware.ts @@ -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).*)'] +};