security: enforce UI route authorization and add custom unauthorized view

This commit is contained in:
Luis Gabriel Ramos Robles 2026-06-12 20:46:29 +00:00
parent 1a71db162f
commit 22358e0df9
20 changed files with 245 additions and 4 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 117 KiB

View file

@ -55,13 +55,52 @@ export default function SalesImportPage() {
const [validationErrors, setValidationErrors] = useState<ValidationError[]>([]);
const [generalError, setGeneralError] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [role, setRole] = useState<string | null>(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 (
<div className={styles.container}>
<Header activeTab="import" />
<main className={styles.main}>
<div className={styles.card} style={{ textAlign: 'center', padding: '2rem' }}>
<p>Cargando módulo de importación...</p>
</div>
</main>
</div>
);
}
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();

View file

@ -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);

View file

@ -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();
}

View file

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

View file

@ -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 (
<div className={styles.container}>
<div className={styles.card}>
<div className={styles.iconContainer}>
<svg
className={styles.icon}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<h1 className={styles.title}>{t('errors.unauthorizedTitle')}</h1>
<p className={styles.description}>{t('errors.unauthorizedDescription')}</p>
<Link href="/plans" className={styles.button}>
{t('errors.unauthorizedBtn')}
</Link>
</div>
</div>
);
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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();