diff --git a/prisma/screenshots-phase4/01_collaborator_unauthorized.png b/prisma/screenshots-phase4/01_collaborator_unauthorized.png index ab80164..1b2d7f5 100644 Binary files a/prisma/screenshots-phase4/01_collaborator_unauthorized.png and b/prisma/screenshots-phase4/01_collaborator_unauthorized.png differ diff --git a/prisma/screenshots-phase4/02_admin_import_view.png b/prisma/screenshots-phase4/02_admin_import_view.png index 21ba539..d400463 100644 Binary files a/prisma/screenshots-phase4/02_admin_import_view.png and b/prisma/screenshots-phase4/02_admin_import_view.png differ diff --git a/prisma/screenshots-phase4/04_validation_errors_rendered.png b/prisma/screenshots-phase4/04_validation_errors_rendered.png index 0ed4d61..ae3c4a8 100644 Binary files a/prisma/screenshots-phase4/04_validation_errors_rendered.png and b/prisma/screenshots-phase4/04_validation_errors_rendered.png differ diff --git a/prisma/screenshots-phase4/05_valid_upload_success.png b/prisma/screenshots-phase4/05_valid_upload_success.png index 2e7a252..596c67c 100644 Binary files a/prisma/screenshots-phase4/05_valid_upload_success.png and b/prisma/screenshots-phase4/05_valid_upload_success.png differ diff --git a/prisma/screenshots-phase5/02_simulation_success.png b/prisma/screenshots-phase5/02_simulation_success.png index 0aa33d9..5b5c441 100644 Binary files a/prisma/screenshots-phase5/02_simulation_success.png and b/prisma/screenshots-phase5/02_simulation_success.png differ diff --git a/prisma/screenshots-phase5/04_rejection_modal.png b/prisma/screenshots-phase5/04_rejection_modal.png index cc1f475..fbfd55d 100644 Binary files a/prisma/screenshots-phase5/04_rejection_modal.png and b/prisma/screenshots-phase5/04_rejection_modal.png differ diff --git a/prisma/screenshots-phase5/05_rejection_done.png b/prisma/screenshots-phase5/05_rejection_done.png index 97014de..bb26021 100644 Binary files a/prisma/screenshots-phase5/05_rejection_done.png and b/prisma/screenshots-phase5/05_rejection_done.png differ diff --git a/prisma/screenshots-phase5/06_clawback_calculated.png b/prisma/screenshots-phase5/06_clawback_calculated.png index 2845828..ad32506 100644 Binary files a/prisma/screenshots-phase5/06_clawback_calculated.png and b/prisma/screenshots-phase5/06_clawback_calculated.png differ diff --git a/prisma/screenshots/02_login_typed.png b/prisma/screenshots/02_login_typed.png index 406d709..da2426c 100644 Binary files a/prisma/screenshots/02_login_typed.png and b/prisma/screenshots/02_login_typed.png differ diff --git a/prisma/screenshots/04_create_modal_open.png b/prisma/screenshots/04_create_modal_open.png index 5b71c37..37cd3d7 100644 Binary files a/prisma/screenshots/04_create_modal_open.png and b/prisma/screenshots/04_create_modal_open.png differ diff --git a/prisma/screenshots/05_create_form_filled.png b/prisma/screenshots/05_create_form_filled.png index a546602..8d6b376 100644 Binary files a/prisma/screenshots/05_create_form_filled.png and b/prisma/screenshots/05_create_form_filled.png differ diff --git a/prisma/screenshots/09_rules_saved_successfully.png b/prisma/screenshots/09_rules_saved_successfully.png index 72f5c60..0f11170 100644 Binary files a/prisma/screenshots/09_rules_saved_successfully.png and b/prisma/screenshots/09_rules_saved_successfully.png differ diff --git a/src/app/sales/import/page.tsx b/src/app/sales/import/page.tsx index 988771a..fe13780 100644 --- a/src/app/sales/import/page.tsx +++ b/src/app/sales/import/page.tsx @@ -55,13 +55,52 @@ export default function SalesImportPage() { const [validationErrors, setValidationErrors] = useState([]); const [generalError, setGeneralError] = useState(null); const [statusMessage, setStatusMessage] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [role, setRole] = useState(null); useEffect(() => { + fetch('/api/auth/me') + .then((res) => { + if (!res.ok) { + router.push('/login'); + return null; + } + return res.json(); + }) + .then((data) => { + if (data && data.user) { + setRole(data.user.role); + const allowed = ['admin', 'analyst', 'commercial_leader']; + if (!allowed.includes(data.user.role)) { + router.push('/unauthorized'); + return; + } + setIsLoading(false); + } + }) + .catch((err) => { + console.error('Failed to get session user details', err); + router.push('/login'); + }); + // Generate unique idempotency key for this session/upload instance const key = 'key-' + Date.now() + '-' + Math.random().toString(36).substring(2, 9); setIdempotencyKey(key); }, []); + if (isLoading) { + return ( +
+
+
+
+

Cargando módulo de importación...

+
+
+
+ ); + } + const handleDrag = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); diff --git a/src/app/sales/simulation/page.tsx b/src/app/sales/simulation/page.tsx index 91de2b4..60da8f5 100644 --- a/src/app/sales/simulation/page.tsx +++ b/src/app/sales/simulation/page.tsx @@ -80,8 +80,8 @@ export default function SimulationPage() { setRole(data.user.role); setCurrentUser(data.user); if (data.user.role !== 'admin' && data.user.role !== 'analyst') { - // Block non-authorized roles - setError('Acceso prohibido. Permisos insuficientes.'); + router.push('/unauthorized'); + return; } } setIsLoading(false); diff --git a/src/app/settlements/approvals/page.tsx b/src/app/settlements/approvals/page.tsx index a18e3d8..0ec5f97 100644 --- a/src/app/settlements/approvals/page.tsx +++ b/src/app/settlements/approvals/page.tsx @@ -88,8 +88,8 @@ export default function ApprovalsPage() { data.user.role !== 'analyst' && data.user.role !== 'director' ) { - setError('Acceso prohibido. Permisos insuficientes.'); - setIsLoading(false); + router.push('/unauthorized'); + return; } else { fetchSettlements(); } diff --git a/src/app/unauthorized/page.module.css b/src/app/unauthorized/page.module.css new file mode 100644 index 0000000..8405afa --- /dev/null +++ b/src/app/unauthorized/page.module.css @@ -0,0 +1,122 @@ +.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.08), transparent 45%), + radial-gradient(circle at bottom left, hsla(var(--secondary-h), var(--secondary-s), var(--secondary-l), 0.06), transparent 40%), + var(--background); + transition: background var(--transition-slow); + position: relative; + overflow: hidden; +} + +.container::before { + content: ''; + position: absolute; + width: 250px; + height: 250px; + border-radius: var(--radius-full); + background: var(--primary); + filter: blur(100px); + opacity: 0.1; + top: 20%; + right: 20%; + pointer-events: none; +} + +.card { + width: 100%; + max-width: 480px; + 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); + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + z-index: 1; +} + +.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.18); +} + +.iconContainer { + width: 64px; + height: 64px; + border-radius: var(--radius-full); + background-color: hsla(0, 85%, 60%, 0.1); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: var(--space-6); + animation: pulseWarning 2s infinite ease-in-out; +} + +[data-theme='dark'] .iconContainer { + background-color: hsla(0, 85%, 50%, 0.2); +} + +@keyframes pulseWarning { + 0% { transform: scale(1); box-shadow: 0 0 0 0 hsla(0, 85%, 60%, 0.4); } + 70% { transform: scale(1.05); box-shadow: 0 0 0 10px hsla(0, 85%, 60%, 0); } + 100% { transform: scale(1); box-shadow: 0 0 0 0 hsla(0, 85%, 60%, 0); } +} + +.icon { + width: 32px; + height: 32px; + color: hsl(0, 85%, 60%); +} + +.title { + font-size: var(--text-2xl); + font-weight: var(--weight-bold); + color: var(--foreground); + margin-bottom: var(--space-3); + letter-spacing: -0.02em; +} + +.description { + font-size: var(--text-base); + color: var(--foreground); + opacity: 0.75; + line-height: 1.5; + margin-bottom: var(--space-8); + max-width: 380px; +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-3) var(--space-6); + 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); + width: 100%; +} + +.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); +} diff --git a/src/app/unauthorized/page.tsx b/src/app/unauthorized/page.tsx new file mode 100644 index 0000000..3b5574f --- /dev/null +++ b/src/app/unauthorized/page.tsx @@ -0,0 +1,37 @@ +'use client'; + +import React from 'react'; +import Link from 'next/link'; +import styles from './page.module.css'; +import { useLocale } from '@/lib/i18n/LocaleContext'; + +export default function UnauthorizedPage() { + const { t } = useLocale(); + + return ( +
+
+
+ + + +
+

{t('errors.unauthorizedTitle')}

+

{t('errors.unauthorizedDescription')}

+ + {t('errors.unauthorizedBtn')} + +
+
+ ); +} diff --git a/src/lib/i18n/dictionaries/en.json b/src/lib/i18n/dictionaries/en.json index 6f2f443..45b570a 100644 --- a/src/lib/i18n/dictionaries/en.json +++ b/src/lib/i18n/dictionaries/en.json @@ -43,5 +43,13 @@ "INDIVIDUAL": "Individual", "TEAM": "Team", "HOTEL": "Hotel" + }, + "errors": { + "unauthorizedTitle": "Access Denied", + "unauthorizedDescription": "You do not have sufficient permissions to access this page. Please contact the administrator if you believe this is an error.", + "unauthorizedBtn": "Back to Plans", + "notFoundTitle": "Page Not Found", + "notFoundDescription": "The page you are looking for does not exist or you do not have authorization to view it.", + "notFoundBtn": "Back to Plans" } } diff --git a/src/lib/i18n/dictionaries/es.json b/src/lib/i18n/dictionaries/es.json index 85a1bfe..3cbd1c9 100644 --- a/src/lib/i18n/dictionaries/es.json +++ b/src/lib/i18n/dictionaries/es.json @@ -43,5 +43,13 @@ "INDIVIDUAL": "Individual", "TEAM": "Equipo", "HOTEL": "Hotel" + }, + "errors": { + "unauthorizedTitle": "Acceso Denegado", + "unauthorizedDescription": "No tiene permisos suficientes para acceder a esta página. Póngase en contacto con el administrador si considera que esto es un error.", + "unauthorizedBtn": "Volver a Planes", + "notFoundTitle": "Página No Encontrada", + "notFoundDescription": "La página que busca no existe o no tiene autorización para acceder a ella.", + "notFoundBtn": "Volver a Planes" } } diff --git a/src/middleware.ts b/src/middleware.ts index 1b43de4..5d969eb 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -50,6 +50,33 @@ export async function middleware(req: NextRequest) { return NextResponse.redirect(loginUrl); } + // Role-based route authorization guards + const role = session.role; + + // 1. /sales/import requires: admin, analyst, commercial_leader + if (pathname.startsWith('/sales/import')) { + const allowed = ['admin', 'analyst', 'commercial_leader']; + if (!allowed.includes(role)) { + return NextResponse.redirect(new URL('/unauthorized', req.url)); + } + } + + // 2. /sales/simulation requires: admin, analyst + if (pathname.startsWith('/sales/simulation')) { + const allowed = ['admin', 'analyst']; + if (!allowed.includes(role)) { + return NextResponse.redirect(new URL('/unauthorized', req.url)); + } + } + + // 3. /settlements/approvals requires: admin, analyst, director, commercial_leader + if (pathname.startsWith('/settlements/approvals')) { + const allowed = ['admin', 'analyst', 'director', 'commercial_leader']; + if (!allowed.includes(role)) { + return NextResponse.redirect(new URL('/unauthorized', req.url)); + } + } + // If accessing API routes, we can inject role headers or simply allow Next.js route guards to handle it const response = NextResponse.next();