feat(auth): add role guards and protect admin routes

This commit is contained in:
2026-06-04 21:57:39 +02:00
parent 601dea9526
commit 12cbec92a0
8 changed files with 114 additions and 27 deletions
@@ -3,7 +3,7 @@
import { AuthError } from "next-auth"
import { signIn } from "@/lib/auth"
import type { SignInFormType } from "@/lib/schemas/auth.schemas"
import type { SignInFormType } from "@/schemas/auth.schema"
export async function signInAction(values: SignInFormType) {
const { username, password } = values
+4 -1
View File
@@ -3,15 +3,18 @@ import { Toaster } from "sonner"
import Navbar from "@/components/layout/navbar"
import AppSidebar from "@/components/layout/sidebar"
import { SidebarProvider } from "@/components/ui/sidebar"
import { auth } from "@/lib/auth"
export default async function LayoutDashboard({
children,
}: {
children: React.ReactNode
}) {
const session = await auth()
return (
<SidebarProvider>
<AppSidebar />
<AppSidebar userRole={session?.user.role} />
<main className="w-full">
<Navbar />
<div className="flex-1 p-6">{children}</div>
+11
View File
@@ -0,0 +1,11 @@
import Link from "next/link"
export default function ForbiddenPage() {
return (
<main>
<h1>Acceso denegado</h1>
<p>No tienes permisos para acceder a esta sección.</p>
<Link href="/">Volver al inicio</Link>
</main>
)
}
+19 -5
View File
@@ -5,12 +5,12 @@ import {
Clipboard,
Home,
Package,
Shield,
ShoppingCart,
User,
} from "lucide-react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import {
Sidebar,
SidebarContent,
@@ -21,6 +21,7 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
import type { UserRole } from "@/generated/prisma/client"
import { SidebarSection } from "./sidebar/sidebarSection"
@@ -72,9 +73,22 @@ const items = [
]
export default function AppSidebar({
userRole,
...props
}: React.ComponentProps<typeof Sidebar>) {
}: React.ComponentProps<typeof Sidebar> & { userRole?: UserRole }) {
const pathname = usePathname()
const visibleItems =
userRole === "ADMIN"
? [
...items,
{
type: "item",
title: "Users",
url: "/admin/users",
icon: Shield,
},
]
: items
return (
<Sidebar {...props}>
@@ -88,7 +102,7 @@ export default function AppSidebar({
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => {
{visibleItems.map((item) => {
if (item.type === "item") {
const isActive =
item.url === "/"
@@ -96,7 +110,7 @@ export default function AppSidebar({
: pathname.startsWith(item.url)
return (
<SidebarMenuItem key={`item-${item}`}>
<SidebarMenuItem key={`item-${item.title}`}>
<SidebarMenuButton asChild isActive={isActive}>
<Link href={item.url}>
<item.icon className="mr-2 h-4 w-4" />
@@ -109,7 +123,7 @@ export default function AppSidebar({
if (item.type === "section") {
return (
<SidebarSection
key={`section-${item}`}
key={`section-${item.title}`}
title={item.title}
icon={item.icon}
items={item.items}
+5 -3
View File
@@ -4,12 +4,11 @@ import { ZodError } from "zod"
import type { UserRole } from "@/generated/prisma/client"
import { SIGN_IN_URL, TOKEN_EXPIRATION_SECONDS } from "@/lib/constants"
import { signInSchema } from "@/lib/schemas/auth.schemas"
import { verifyPassword } from "@/lib/security"
import { signInSchema } from "@/schemas/auth.schema"
import { getUserByUsername } from "@/services/user.service"
declare module "next-auth" {
// eslint-disable-next-line no-unused-vars
interface Session {
user: {
id: string
@@ -17,7 +16,6 @@ declare module "next-auth" {
} & DefaultSession["user"]
}
// eslint-disable-next-line no-unused-vars
interface User {
role: UserRole
}
@@ -46,6 +44,10 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
throw new Error("Invalid username or password")
}
if (!user.isActive) {
throw new Error("Invalid username or password")
}
if (!(await verifyPassword(data.password, user.password)))
throw new Error("Invalid username or password")
+7 -1
View File
@@ -2,9 +2,11 @@ import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { SIGN_IN_URL } from "@/lib/constants"
import { isAdmin } from "@/services/auth.service"
export default auth((req) => {
const isLogged = !!req.auth
const session = req.auth
const isLogged = !!session?.user?.id
const { pathname } = req.nextUrl
if (isLogged && pathname === SIGN_IN_URL) {
@@ -18,6 +20,10 @@ export default auth((req) => {
return NextResponse.redirect(newUrl)
}
if (!isAdmin(session) && pathname.startsWith("/admin")) {
return NextResponse.redirect(new URL("/forbidden", req.nextUrl.origin))
}
return NextResponse.next()
})
@@ -4,13 +4,13 @@ export const signInSchema = z.object({
username: z
.string()
.min(1, {
error: "Invalid username"
error: "Invalid username",
})
.nonempty("Username is required"),
password: z
.string()
.min(3, {
error: "Password is too short"
error: "Password is too short",
})
.nonempty("Password is required"),
})
+65 -14
View File
@@ -1,8 +1,15 @@
// lib/server/auth-utils.ts (o directamente en lib/auth.ts si prefieres)
import { redirect } from "next/navigation"
import type { Session } from "next-auth"
import { auth } from "@/lib/auth" // Asegúrate de que tu instancia de auth de Auth.js se exporte desde aquí
import type { UserRole } from "@/generated/prisma/client"
import { auth } from "@/lib/auth"
const ROLE_HIERARCHY = {
VIEWER: 0,
STAFF: 1,
MANAGER: 2,
ADMIN: 3,
} satisfies Record<UserRole, number>
/**
* Obtiene la sesión del usuario. Si no está autenticado, redirige a la página de inicio de sesión.
@@ -12,13 +19,10 @@ import { auth } from "@/lib/auth" // Asegúrate de que tu instancia de auth de A
export async function getAuthenticatedSession(): Promise<Session> {
const session = await auth()
if(!session?.user?.id){
// Redirige a la página de login. Puedes personalizar la URL.
// También puedes lanzar un error si prefieres que el Server Action lo capture.
redirect("/login") // O throw new Error("Unauthorized");
if (!session?.user?.id) {
redirect("/login")
}
// Si necesitas asegurar que ciertos campos existen o que la sesión sea de un tipo específico
return session as Session
}
@@ -31,16 +35,63 @@ export async function getAuthenticatedUserId(): Promise<string> {
return session.user.id
}
export function hasRole(session: Session | null, requiredRole: UserRole) {
return session?.user?.role === requiredRole
}
export function hasAnyRole(session: Session | null, roles: UserRole[]) {
return roles.some((role) => hasRole(session, role))
}
export function hasMinimumRole(session: Session | null, minimumRole: UserRole) {
const role = session?.user?.role
if (!role) return false
return ROLE_HIERARCHY[role] >= ROLE_HIERARCHY[minimumRole]
}
export function isAdmin(session: Session | null) {
return hasRole(session, "ADMIN")
}
export async function requireRole(requiredRole: UserRole): Promise<Session> {
const session = await getAuthenticatedSession()
if (!hasRole(session, requiredRole)) {
redirect("/forbidden")
}
return session
}
export async function requireAnyRole(roles: UserRole[]): Promise<Session> {
const session = await getAuthenticatedSession()
if (!hasAnyRole(session, roles)) {
redirect("/forbidden")
}
return session
}
export async function requireMinimumRole(
minimumRole: UserRole,
): Promise<Session> {
const session = await getAuthenticatedSession()
if (!hasMinimumRole(session, minimumRole)) {
redirect("/forbidden")
}
return session
}
/**
* Verifica si el usuario tiene un rol específico.
* @param requiredRole El rol requerido.
* @throws Error si el usuario no tiene el rol.
*/
export async function verifyUserRole(
requiredRole: "ADMIN" | "USER",
): Promise<void> {
const session = await getAuthenticatedSession()
if (session.user.role !== requiredRole) {
throw new Error("Forbidden: Insufficient permissions.") // O redirigir a una página de acceso denegado
}
export async function verifyUserRole(requiredRole: UserRole): Promise<void> {
await requireRole(requiredRole)
}