security: enforce UI route authorization and add custom unauthorized view
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 117 KiB |
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
122
src/app/unauthorized/page.module.css
Normal 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);
|
||||
}
|
||||
37
src/app/unauthorized/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||