feat(auth): add /login page, session middleware, role-based navigation and logout
This commit is contained in:
10
src/app/auth/actions.ts
Normal file
10
src/app/auth/actions.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export async function logoutAction() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
redirect('/login');
|
||||||
|
}
|
||||||
@@ -6,18 +6,30 @@ import {
|
|||||||
FolderGit2,
|
FolderGit2,
|
||||||
Bot,
|
Bot,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Layers
|
Layers,
|
||||||
|
LogOut,
|
||||||
|
UserCheck
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { logoutAction } from '@/app/auth/actions';
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default async function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
const role = user?.user_metadata?.role || user?.app_metadata?.role || 'client';
|
||||||
|
const company = user?.user_metadata?.company || '4Ventures Central';
|
||||||
|
const email = user?.email || 'usuario@4ventures.es';
|
||||||
|
const isAgency = role === 'admin' || role === 'team';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-[#0B0F19]">
|
<div className="flex min-h-screen bg-[#0B0F19]">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside className="w-64 border-r border-gray-800 bg-[#0D121F] flex flex-col justify-between p-4">
|
<aside className="w-64 border-r border-gray-800 bg-[#0D121F] flex flex-col justify-between p-4 shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3 px-2 py-4 border-b border-gray-800/80 mb-6">
|
<div className="flex items-center gap-3 px-2 py-4 border-b border-gray-800/80 mb-6">
|
||||||
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-brand-500 to-amber-600 flex items-center justify-center font-bold text-white shadow-lg shadow-brand-500/20">
|
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-brand-500 to-amber-600 flex items-center justify-center font-bold text-white shadow-lg shadow-brand-500/20">
|
||||||
@@ -58,6 +70,8 @@ export default function DashboardLayout({
|
|||||||
<Bot className="w-4 h-4 text-purple-400" />
|
<Bot className="w-4 h-4 text-purple-400" />
|
||||||
Asistente IA Financiero
|
Asistente IA Financiero
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{isAgency && (
|
||||||
<div className="pt-4 mt-4 border-t border-gray-800/60">
|
<div className="pt-4 mt-4 border-t border-gray-800/60">
|
||||||
<span className="px-3 text-[10px] font-bold uppercase tracking-wider text-gray-400">Agencia OS</span>
|
<span className="px-3 text-[10px] font-bold uppercase tracking-wider text-gray-400">Agencia OS</span>
|
||||||
<Link
|
<Link
|
||||||
@@ -68,17 +82,41 @@ export default function DashboardLayout({
|
|||||||
Cartera Cross-Tenant
|
Cartera Cross-Tenant
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-4 border-t border-gray-800/80 text-xs text-gray-400 space-y-2">
|
{/* Sidebar Footer with User Info & Logout */}
|
||||||
<div className="flex items-center justify-between px-2">
|
<div className="pt-4 border-t border-gray-800/80 space-y-3">
|
||||||
<span className="flex items-center gap-1.5 text-emerald-400">
|
<div className="px-2 py-2 rounded-xl bg-gray-900/60 border border-gray-800/80">
|
||||||
<ShieldCheck className="w-3.5 h-3.5" /> Aislamiento RLS
|
<div className="flex items-center gap-2 mb-1">
|
||||||
</span>
|
<UserCheck className="w-3.5 h-3.5 text-brand-400 shrink-0" />
|
||||||
<span className="bg-gray-800 px-1.5 py-0.5 rounded text-[10px] text-gray-300">VPS Hostinger</span>
|
<p className="text-xs font-medium text-white truncate">{email}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[10px] text-gray-400 capitalize">{company}</span>
|
||||||
|
<span className="text-[9px] uppercase tracking-wider font-semibold px-1.5 py-0.5 rounded bg-brand-500/20 text-brand-400">
|
||||||
|
{role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={logoutAction}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-gray-400 hover:text-red-400 hover:bg-red-500/10 border border-transparent hover:border-red-500/20 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<LogOut className="w-3.5 h-3.5" />
|
||||||
|
Cerrar Sesión
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between px-2 pt-1 text-[11px] text-gray-500">
|
||||||
|
<span className="flex items-center gap-1 text-emerald-400">
|
||||||
|
<ShieldCheck className="w-3 h-3" /> RLS Activo
|
||||||
|
</span>
|
||||||
|
<span>VPS Hostinger</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="px-2 text-[11px] text-gray-500">Hostinger VPS · Dokploy</p>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -86,9 +124,9 @@ export default function DashboardLayout({
|
|||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
<header className="h-16 border-b border-gray-800 bg-[#0D121F]/50 backdrop-blur px-8 flex items-center justify-between">
|
<header className="h-16 border-b border-gray-800 bg-[#0D121F]/50 backdrop-blur px-8 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
<span>Tenant:</span>
|
<span>Cliente / Portal:</span>
|
||||||
<span className="bg-brand-500/10 text-brand-500 font-semibold px-2 py-0.5 rounded border border-brand-500/20">
|
<span className="bg-brand-500/10 text-brand-400 font-semibold px-2.5 py-1 rounded-md border border-brand-500/20">
|
||||||
4Ventures Central
|
{company}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|||||||
168
src/app/login/page.tsx
Normal file
168
src/app/login/page.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, Suspense } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { Mail, Lock, Eye, EyeOff, ShieldCheck, ArrowRight, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
function LoginForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const next = searchParams.get('next') || '/dashboard/kpis';
|
||||||
|
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setErrorMsg(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supabase = createClient();
|
||||||
|
const { data, error } = await supabase.auth.signInWithPassword({
|
||||||
|
email: email.trim(),
|
||||||
|
password: password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setErrorMsg('Credenciales inválidas. Verifica tu correo y contraseña.');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.session) {
|
||||||
|
router.push(next);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setErrorMsg(err?.message || 'Error de conexión con el servidor de autenticación.');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[#0D121F] border border-gray-800/80 rounded-2xl p-8 shadow-2xl shadow-black/50 backdrop-blur-sm">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-white">Iniciar Sesión</h2>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">Introduce tus credenciales corporativas para acceder a tu portal.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="mb-6 p-3.5 rounded-xl bg-red-500/10 border border-red-500/30 text-red-400 text-xs flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-red-500 shrink-0" />
|
||||||
|
<span>{errorMsg}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-1.5">
|
||||||
|
Correo Electrónico
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-gray-400">
|
||||||
|
<Mail className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="ejemplo@cliente.com"
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-gray-900/80 border border-gray-800 rounded-xl text-sm text-white placeholder-gray-500 focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-300 mb-1.5">
|
||||||
|
Contraseña
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-gray-400">
|
||||||
|
<Lock className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••••••"
|
||||||
|
className="w-full pl-10 pr-10 py-2.5 bg-gray-900/80 border border-gray-800 rounded-xl text-sm text-white placeholder-gray-500 focus:outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500 transition-all"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute inset-y-0 right-0 pr-3.5 flex items-center text-gray-400 hover:text-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full mt-2 py-3 px-4 rounded-xl bg-gradient-to-r from-brand-500 to-amber-600 hover:from-brand-600 hover:to-amber-700 text-white font-medium text-sm shadow-lg shadow-brand-500/20 flex items-center justify-center gap-2 transition-all disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span>Verificando credenciales...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>Acceder al Centro de Mando</span>
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#0B0F19] text-gray-100 flex flex-col justify-center items-center px-4 relative overflow-hidden">
|
||||||
|
{/* Background Glow */}
|
||||||
|
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[350px] bg-gradient-to-tr from-brand-500/10 via-amber-500/5 to-purple-600/10 blur-[120px] rounded-full pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="w-full max-w-md relative z-10">
|
||||||
|
{/* Header Branding */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-gradient-to-br from-brand-500 to-amber-600 text-white font-bold text-xl shadow-xl shadow-brand-500/20 mb-4 border border-brand-400/30">
|
||||||
|
4V
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-white">4VENTURES</h1>
|
||||||
|
<p className="text-sm text-gray-400 mt-1 font-medium">Centro de Mando del Cliente</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login Box wrapped in Suspense for useSearchParams */}
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="bg-[#0D121F] border border-gray-800/80 rounded-2xl p-8 flex justify-center items-center h-64">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-brand-500" />
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<LoginForm />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
{/* Security Footer */}
|
||||||
|
<div className="mt-8 flex items-center justify-center gap-4 text-[11px] text-gray-400">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
<span>Aislamiento RLS Multi-Tenant</span>
|
||||||
|
</div>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Cifrado TLS v1.3</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Dokploy VPS</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
|
||||||
export default function Home() {
|
export default async function Home() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (user) {
|
||||||
redirect('/dashboard/kpis');
|
redirect('/dashboard/kpis');
|
||||||
|
} else {
|
||||||
|
redirect('/login');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,71 @@
|
|||||||
|
import { createServerClient } from '@supabase/ssr';
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
export function middleware(req: NextRequest) {
|
export async function middleware(req: NextRequest) {
|
||||||
const url = req.nextUrl.clone();
|
let res = NextResponse.next({
|
||||||
const hostname = req.headers.get('host') || '';
|
request: {
|
||||||
|
headers: req.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// 1. Extraer slug desde subdominio (ej: acme.4ventures.es)
|
const supabase = createServerClient(
|
||||||
let tenantSlug = '4ventures-hub';
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return req.cookies.getAll();
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
cookiesToSet.forEach(({ name, value }) => req.cookies.set(name, value));
|
||||||
|
res = NextResponse.next({
|
||||||
|
request: {
|
||||||
|
headers: req.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
res.cookies.set(name, value, options)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
const url = req.nextUrl.clone();
|
||||||
|
const path = url.pathname;
|
||||||
|
|
||||||
|
// 1. Redirigir usuarios no autenticados que intenten acceder al dashboard
|
||||||
|
if (!user && (path === '/' || path.startsWith('/dashboard'))) {
|
||||||
|
const loginUrl = req.nextUrl.clone();
|
||||||
|
loginUrl.pathname = '/login';
|
||||||
|
if (path.startsWith('/dashboard')) {
|
||||||
|
loginUrl.searchParams.set('next', path);
|
||||||
|
}
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Redirigir usuarios autenticados que intenten acceder a /login
|
||||||
|
if (user && path === '/login') {
|
||||||
|
const dashboardUrl = req.nextUrl.clone();
|
||||||
|
dashboardUrl.pathname = '/dashboard/kpis';
|
||||||
|
return NextResponse.redirect(dashboardUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Proteger la vista directiva de agencia contra clientes
|
||||||
|
const role = user?.app_metadata?.role || user?.user_metadata?.role || 'client';
|
||||||
|
if (user && role === 'client' && path.startsWith('/dashboard/agencia')) {
|
||||||
|
const forbiddenUrl = req.nextUrl.clone();
|
||||||
|
forbiddenUrl.pathname = '/dashboard/kpis';
|
||||||
|
return NextResponse.redirect(forbiddenUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Resolver Tenant Slug (desde subdominio, metadata de usuario o cookie)
|
||||||
|
let tenantSlug = user?.user_metadata?.slug || user?.app_metadata?.slug || '4ventures-hub';
|
||||||
|
const hostname = req.headers.get('host') || '';
|
||||||
const parts = hostname.split('.');
|
const parts = hostname.split('.');
|
||||||
|
|
||||||
if (parts.length > 2) {
|
if (parts.length > 2) {
|
||||||
@@ -15,29 +75,26 @@ export function middleware(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Override opcional vía query param ?tenant=slug
|
|
||||||
const queryTenant = url.searchParams.get('tenant');
|
const queryTenant = url.searchParams.get('tenant');
|
||||||
if (queryTenant) {
|
if (queryTenant) {
|
||||||
tenantSlug = queryTenant.toLowerCase();
|
tenantSlug = queryTenant.toLowerCase();
|
||||||
} else {
|
} else {
|
||||||
const cookieTenant = req.cookies.get('4v_tenant')?.value;
|
const cookieTenant = req.cookies.get('4v_tenant')?.value;
|
||||||
if (cookieTenant) {
|
if (cookieTenant && !user?.user_metadata?.slug) {
|
||||||
tenantSlug = cookieTenant;
|
tenantSlug = cookieTenant;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = NextResponse.next({
|
res.headers.set('x-tenant-slug', tenantSlug);
|
||||||
request: {
|
res.headers.set('x-user-role', role);
|
||||||
headers: new Headers(req.headers)
|
if (user?.email) {
|
||||||
|
res.headers.set('x-user-email', user.email);
|
||||||
}
|
}
|
||||||
});
|
res.cookies.set('4v_tenant', tenantSlug, { path: '/', maxAge: 60 * 60 * 24 * 30 });
|
||||||
|
|
||||||
response.headers.set('x-tenant-slug', tenantSlug);
|
return res;
|
||||||
response.cookies.set('4v_tenant', tenantSlug, { path: '/', maxAge: 60 * 60 * 24 * 30 });
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ['/((?!_next/static|_next/image|favicon.ico|_4v_attr.js).*)']
|
matcher: ['/((?!_next/static|_next/image|favicon.ico|_4v_attr.js|api/chat|api/simulate).*)']
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user