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).
|
||||
|
||||
## 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
20
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
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