From b7fd15ffcd2866d4832a78e92d38c2fe726cba55 Mon Sep 17 00:00:00 2001 From: AV77web Date: Sun, 31 May 2026 17:41:45 +0200 Subject: [PATCH] inserita la gestione degli utenti --- src/app/(main)/layout.tsx | 29 +- src/app/(management)/layout.tsx | 28 +- src/app/(management)/stabilimenti/page.tsx | 16 +- src/app/(management)/utenti/page.tsx | 29 +- .../[idmacchina]/[idart]/route.ts | 9 +- .../api/art-macchine-parti/assegna/route.ts | 9 +- src/app/api/macchine/[id]/route.ts | 10 +- src/app/api/macchine/route.ts | 8 +- src/app/api/magazzini/[id]/route.ts | 15 +- src/app/api/magazzini/route.ts | 8 +- src/app/api/movimenti/route.ts | 14 +- src/app/api/stabilimenti/[id]/route.ts | 15 +- src/app/api/stabilimenti/route.ts | 8 +- src/app/api/utenti/[id]/route.ts | 88 ++++ src/app/api/utenti/route.ts | 36 ++ src/auth.ts | 16 +- src/components/AppShell.tsx | 38 ++ src/components/ArticleCard.tsx | 7 +- src/components/ArticlesList.tsx | 5 + src/components/ArticoliMacchinaModal.tsx | 7 +- src/components/MacchineTree.tsx | 54 ++- src/components/Navbar.tsx | 128 +++--- src/components/TreeNode.tsx | 17 +- src/components/UtentiManager.tsx | 389 ++++++++++++++++++ src/contexts/PermissionsContext.tsx | 37 ++ src/lib/auth/requireSession.ts | 62 +++ src/lib/permissions.ts | 72 ++++ src/lib/users/index.ts | 187 +++++++++ src/types/user.ts | 20 +- 29 files changed, 1145 insertions(+), 216 deletions(-) create mode 100644 src/app/api/utenti/[id]/route.ts create mode 100644 src/app/api/utenti/route.ts create mode 100644 src/components/AppShell.tsx create mode 100644 src/components/UtentiManager.tsx create mode 100644 src/contexts/PermissionsContext.tsx create mode 100644 src/lib/auth/requireSession.ts create mode 100644 src/lib/permissions.ts create mode 100644 src/lib/users/index.ts diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index 73f7760..67475a9 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -1,28 +1,5 @@ -import { redirect } from "next/navigation"; +import AppShell from "@/src/components/AppShell"; -import { auth } from "@/src/auth"; -import SignOutButton from "@/src/components/auth/SignOutButton"; -import Navbar from "@/src/components/Navbar"; -import { StabilimentoProvider } from "@/src/contexts/StabilimentoContext"; - -export default async function MainLayout({ - children, - -}: { - children: React.ReactNode; -}) { - const session = await auth(); - - if (!session?.user) { - redirect("/login"); - } - - return ( - -
- } /> -
{children}
-
-
- ); +export default function MainLayout({ children }: { children: React.ReactNode }) { + return {children}; } diff --git a/src/app/(management)/layout.tsx b/src/app/(management)/layout.tsx index 1573611..263a174 100644 --- a/src/app/(management)/layout.tsx +++ b/src/app/(management)/layout.tsx @@ -1,27 +1,5 @@ -import { redirect } from "next/navigation"; +import AppShell from "@/src/components/AppShell"; -import { auth } from "@/src/auth"; -import SignOutButton from "@/src/components/auth/SignOutButton"; -import Navbar from "@/src/components/Navbar"; -import { StabilimentoProvider } from "@/src/contexts/StabilimentoContext"; - -export default async function ManagementLayout({ - children, -}: { - children: React.ReactNode; -}) { - const session = await auth(); - - if (!session?.user) { - redirect("/login"); - } - - return ( - -
- } /> -
{children}
-
-
- ); +export default function ManagementLayout({ children }: { children: React.ReactNode }) { + return {children}; } diff --git a/src/app/(management)/stabilimenti/page.tsx b/src/app/(management)/stabilimenti/page.tsx index 4afb86e..2a1a12a 100644 --- a/src/app/(management)/stabilimenti/page.tsx +++ b/src/app/(management)/stabilimenti/page.tsx @@ -1,8 +1,20 @@ -"use client"; +import { redirect } from "next/navigation"; +import { auth } from "@/src/auth"; import StabilimentiManager from "@/src/components/StabilimentiManager"; +import { getPermissions, normalizeRole } from "@/src/lib/permissions"; + +export default async function StabilimentiPage() { + const session = await auth(); + if (!session?.user) { + redirect("/login"); + } + + const ruolo = normalizeRole(session.user.ruolo); + if (!ruolo || !getPermissions(ruolo).canManageStabilimenti) { + redirect("/"); + } -export default function StabilimentiPage() { return (
diff --git a/src/app/(management)/utenti/page.tsx b/src/app/(management)/utenti/page.tsx index 1cf70ad..b5d074f 100644 --- a/src/app/(management)/utenti/page.tsx +++ b/src/app/(management)/utenti/page.tsx @@ -1,8 +1,23 @@ -import Link from "next/link"; +import { redirect } from "next/navigation"; -export default function Utenti() { - return <> -

Utenti

- Utenti - -} \ No newline at end of file +import { auth } from "@/src/auth"; +import UtentiManager from "@/src/components/UtentiManager"; +import { getPermissions, normalizeRole } from "@/src/lib/permissions"; + +export default async function UtentiPage() { + const session = await auth(); + if (!session?.user) { + redirect("/login"); + } + + const ruolo = normalizeRole(session.user.ruolo); + if (!ruolo || !getPermissions(ruolo).canManageUtenti) { + redirect("/"); + } + + return ( +
+ +
+ ); +} diff --git a/src/app/api/art-macchine-parti/[idmacchina]/[idart]/route.ts b/src/app/api/art-macchine-parti/[idmacchina]/[idart]/route.ts index 5e36ebd..bdac23b 100644 --- a/src/app/api/art-macchine-parti/[idmacchina]/[idart]/route.ts +++ b/src/app/api/art-macchine-parti/[idmacchina]/[idart]/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import { auth } from "@/src/auth"; +import { requirePermission } from "@/src/lib/auth/requireSession"; import { rimuoviArticoloDaMacchina } from "@/src/lib/artMacchineParti"; interface RouteParams { @@ -8,11 +8,8 @@ interface RouteParams { } export async function DELETE(request: Request, { params }: RouteParams) { - const session = await auth(); - - if (!session?.user) { - return NextResponse.json({ error: "Non autorizzato" }, { status: 401 }); - } + const session = await requirePermission((p) => p.canRemoveArticoloMacchina); + if (session instanceof NextResponse) return session; const { idmacchina, idart } = await params; const macchinaId = parseInt(idmacchina, 10); diff --git a/src/app/api/art-macchine-parti/assegna/route.ts b/src/app/api/art-macchine-parti/assegna/route.ts index bc51065..f888593 100644 --- a/src/app/api/art-macchine-parti/assegna/route.ts +++ b/src/app/api/art-macchine-parti/assegna/route.ts @@ -1,14 +1,11 @@ import { NextResponse } from "next/server"; -import { auth } from "@/src/auth"; +import { requirePermission } from "@/src/lib/auth/requireSession"; import { assegnaArticoloMacchina } from "@/src/lib/artMacchineParti"; export async function POST(request: Request) { - const session = await auth(); - - if (!session?.user) { - return NextResponse.json({ error: "Non autorizzato" }, { status: 401 }); - } + const session = await requirePermission((p) => p.canAssegnaArticoliMacchina); + if (session instanceof NextResponse) return session; try { const body = await request.json(); diff --git a/src/app/api/macchine/[id]/route.ts b/src/app/api/macchine/[id]/route.ts index 8c9dd77..1eba494 100644 --- a/src/app/api/macchine/[id]/route.ts +++ b/src/app/api/macchine/[id]/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { auth } from "@/src/auth"; +import { requirePermission } from "@/src/lib/auth/requireSession"; import { getMacchinaById, updateMacchina, @@ -47,11 +48,8 @@ export async function GET(request: Request, { params }: RouteParams) { } export async function PUT(request: Request, { params }: RouteParams) { - const session = await auth(); - - if (!session?.user) { - return NextResponse.json({ error: "Non autorizzato" }, { status: 401 }); - } + const session = await requirePermission((p) => p.canEditMacchine); + if (session instanceof NextResponse) return session; const { id } = await params; const macchinaId = parseInt(id, 10); @@ -116,7 +114,7 @@ export async function DELETE(request: Request, { params }: RouteParams) { // Body vuoto o non JSON, continua senza magazzino } - const user = session.user.email || session.user.name || "unknown"; + const user = session.user.email || session.user.utente || "unknown"; const deleted = await deleteMacchina(macchinaId, idMagazzinoRientro, user); if (!deleted) { diff --git a/src/app/api/macchine/route.ts b/src/app/api/macchine/route.ts index afad7f9..17b3e99 100644 --- a/src/app/api/macchine/route.ts +++ b/src/app/api/macchine/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { auth } from "@/src/auth"; +import { requirePermission } from "@/src/lib/auth/requireSession"; import { listMacchine, createMacchina, @@ -28,11 +29,8 @@ export async function GET() { } export async function POST(request: Request) { - const session = await auth(); - - if (!session?.user) { - return NextResponse.json({ error: "Non autorizzato" }, { status: 401 }); - } + const session = await requirePermission((p) => p.canEditMacchine); + if (session instanceof NextResponse) return session; try { const body = await request.json(); diff --git a/src/app/api/magazzini/[id]/route.ts b/src/app/api/magazzini/[id]/route.ts index d473712..1d44779 100644 --- a/src/app/api/magazzini/[id]/route.ts +++ b/src/app/api/magazzini/[id]/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { auth } from "@/src/auth"; +import { requirePermission } from "@/src/lib/auth/requireSession"; import { getMagazzinoById, updateMagazzino, deleteMagazzino } from "@/src/lib/stabilimenti"; interface RouteContext { @@ -40,11 +41,8 @@ export async function GET(_request: Request, context: RouteContext) { } export async function PUT(request: Request, context: RouteContext) { - const session = await auth(); - - if (!session?.user) { - return NextResponse.json({ error: "Non autorizzato" }, { status: 401 }); - } + const session = await requirePermission((p) => p.canManageStabilimenti); + if (session instanceof NextResponse) return session; try { const magazzino = await updateMagazzino( @@ -61,11 +59,8 @@ export async function PUT(request: Request, context: RouteContext) { } export async function DELETE(_request: Request, context: RouteContext) { - const session = await auth(); - - if (!session?.user) { - return NextResponse.json({ error: "Non autorizzato" }, { status: 401 }); - } + const session = await requirePermission((p) => p.canManageStabilimenti); + if (session instanceof NextResponse) return session; try { await deleteMagazzino(await readId(context.params)); diff --git a/src/app/api/magazzini/route.ts b/src/app/api/magazzini/route.ts index 599e6be..e063f03 100644 --- a/src/app/api/magazzini/route.ts +++ b/src/app/api/magazzini/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { auth } from "@/src/auth"; +import { requirePermission } from "@/src/lib/auth/requireSession"; import { listMagazzini, createMagazzino } from "@/src/lib/stabilimenti"; export async function GET(request: Request) { @@ -29,11 +30,8 @@ export async function GET(request: Request) { } export async function POST(request: Request) { - const session = await auth(); - - if (!session?.user) { - return NextResponse.json({ error: "Non autorizzato" }, { status: 401 }); - } + const session = await requirePermission((p) => p.canManageStabilimenti); + if (session instanceof NextResponse) return session; try { const magazzino = await createMagazzino(await request.json()); diff --git a/src/app/api/movimenti/route.ts b/src/app/api/movimenti/route.ts index 16fac46..2a2decd 100644 --- a/src/app/api/movimenti/route.ts +++ b/src/app/api/movimenti/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { auth } from "@/src/auth"; +import { forbid, requireSession } from "@/src/lib/auth/requireSession"; import { listMovimenti, createMovimento } from "@/src/lib/movimenti"; import { getArticleById } from "@/src/lib/articles"; import type { TipoMovimento } from "@/src/types/movimento"; @@ -35,11 +36,8 @@ export async function GET(request: Request) { } export async function POST(request: Request) { - const session = await auth(); - - if (!session?.user) { - return NextResponse.json({ error: "Non autorizzato" }, { status: 401 }); - } + const session = await requireSession(); + if (session instanceof NextResponse) return session; try { const body = await request.json(); @@ -72,6 +70,10 @@ export async function POST(request: Request) { ); } + if (tipo === "scarico" && !session.permissions.canScaricoArticoli) { + return forbid("Scarico non consentito per il tuo ruolo"); + } + const articolo = await getArticleById(id_articolo); if (!articolo) { return NextResponse.json( @@ -81,7 +83,7 @@ export async function POST(request: Request) { } const tipoMovimentoDB = tipo === "carico" ? 1 : 2; - const utente = session.user.name || session.user.email || "Sistema"; + const utente = session.user.utente || session.user.name || session.user.email || "Sistema"; const movimentoId = await createMovimento({ id_articolo, diff --git a/src/app/api/stabilimenti/[id]/route.ts b/src/app/api/stabilimenti/[id]/route.ts index 3bff566..cef75aa 100644 --- a/src/app/api/stabilimenti/[id]/route.ts +++ b/src/app/api/stabilimenti/[id]/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { auth } from "@/src/auth"; +import { requirePermission } from "@/src/lib/auth/requireSession"; import { getStabilimentoById, updateStabilimento, @@ -44,11 +45,8 @@ export async function GET(_request: Request, context: RouteContext) { } export async function PUT(request: Request, context: RouteContext) { - const session = await auth(); - - if (!session?.user) { - return NextResponse.json({ error: "Non autorizzato" }, { status: 401 }); - } + const session = await requirePermission((p) => p.canManageStabilimenti); + if (session instanceof NextResponse) return session; try { const stabilimento = await updateStabilimento( @@ -65,11 +63,8 @@ export async function PUT(request: Request, context: RouteContext) { } export async function DELETE(_request: Request, context: RouteContext) { - const session = await auth(); - - if (!session?.user) { - return NextResponse.json({ error: "Non autorizzato" }, { status: 401 }); - } + const session = await requirePermission((p) => p.canManageStabilimenti); + if (session instanceof NextResponse) return session; try { await deleteStabilimento(await readId(context.params)); diff --git a/src/app/api/stabilimenti/route.ts b/src/app/api/stabilimenti/route.ts index 926f05d..f5b286a 100644 --- a/src/app/api/stabilimenti/route.ts +++ b/src/app/api/stabilimenti/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { auth } from "@/src/auth"; +import { requirePermission } from "@/src/lib/auth/requireSession"; import { listStabilimenti, getStabilimentiWithMagazzini, @@ -35,11 +36,8 @@ export async function GET(request: Request) { } export async function POST(request: Request) { - const session = await auth(); - - if (!session?.user) { - return NextResponse.json({ error: "Non autorizzato" }, { status: 401 }); - } + const session = await requirePermission((p) => p.canManageStabilimenti); + if (session instanceof NextResponse) return session; try { const stabilimento = await createStabilimento(await request.json()); diff --git a/src/app/api/utenti/[id]/route.ts b/src/app/api/utenti/[id]/route.ts new file mode 100644 index 0000000..18e017f --- /dev/null +++ b/src/app/api/utenti/[id]/route.ts @@ -0,0 +1,88 @@ +import { NextResponse } from "next/server"; + +import { forbid, requirePermission } from "@/src/lib/auth/requireSession"; +import { deleteUser, getUserById, updateUser } from "@/src/lib/users"; + +type RouteContext = { params: Promise<{ id: string }> }; + +export async function GET(_request: Request, context: RouteContext) { + const session = await requirePermission((p) => p.canManageUtenti); + if (session instanceof NextResponse) return session; + + const { id } = await context.params; + const userId = parseInt(id, 10); + + if (!Number.isInteger(userId) || userId <= 0) { + return NextResponse.json({ error: "ID non valido" }, { status: 400 }); + } + + try { + const utente = await getUserById(userId); + if (!utente) { + return NextResponse.json({ error: "Utente non trovato" }, { status: 404 }); + } + return NextResponse.json({ utente }); + } catch (error) { + console.error("[API utenti/id] Errore GET:", error); + return NextResponse.json({ error: "Errore nel recupero" }, { status: 500 }); + } +} + +export async function PUT(request: Request, context: RouteContext) { + const session = await requirePermission((p) => p.canManageUtenti); + if (session instanceof NextResponse) return session; + + const { id } = await context.params; + const userId = parseInt(id, 10); + const currentId = parseInt(session.user.id, 10); + + if (!Number.isInteger(userId) || userId <= 0) { + return NextResponse.json({ error: "ID non valido" }, { status: 400 }); + } + + try { + const body = await request.json(); + + if (userId === currentId && body.ruolo && body.ruolo !== session.user.ruolo) { + return forbid("Non puoi modificare il tuo ruolo."); + } + if (userId === currentId && body.attivo === 0) { + return forbid("Non puoi disattivare il tuo account."); + } + + const utente = await updateUser(userId, body); + return NextResponse.json({ utente }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Errore aggiornamento" }, + { status: 400 }, + ); + } +} + +export async function DELETE(_request: Request, context: RouteContext) { + const session = await requirePermission((p) => p.canManageUtenti); + if (session instanceof NextResponse) return session; + + const { id } = await context.params; + const userId = parseInt(id, 10); + const currentId = parseInt(session.user.id, 10); + + if (!Number.isInteger(userId) || userId <= 0) { + return NextResponse.json({ error: "ID non valido" }, { status: 400 }); + } + + if (userId === currentId) { + return forbid("Non puoi eliminare il tuo account."); + } + + try { + await deleteUser(userId); + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Errore eliminazione" }, + { status: 400 }, + ); + } +} diff --git a/src/app/api/utenti/route.ts b/src/app/api/utenti/route.ts new file mode 100644 index 0000000..06d3cea --- /dev/null +++ b/src/app/api/utenti/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; + +import { requirePermission } from "@/src/lib/auth/requireSession"; +import { createUser, listUsers } from "@/src/lib/users"; + +export async function GET() { + const session = await requirePermission((p) => p.canManageUtenti); + if (session instanceof NextResponse) return session; + + try { + const utenti = await listUsers(); + return NextResponse.json({ utenti }); + } catch (error) { + console.error("[API utenti] Errore GET:", error); + return NextResponse.json( + { error: "Errore nel recupero degli utenti" }, + { status: 500 }, + ); + } +} + +export async function POST(request: Request) { + const session = await requirePermission((p) => p.canManageUtenti); + if (session instanceof NextResponse) return session; + + try { + const body = await request.json(); + const utente = await createUser(body); + return NextResponse.json({ utente }, { status: 201 }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Errore creazione utente" }, + { status: 400 }, + ); + } +} diff --git a/src/auth.ts b/src/auth.ts index a0aa73b..5fff024 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -4,7 +4,8 @@ import Credentials from "next-auth/providers/credentials"; import type { ResultSetHeader, RowDataPacket } from "mysql2"; import { db } from "@/src/lib/db"; -import type { DbUser, UserRole } from "@/src/types/user"; +import { normalizeRole } from "@/src/lib/permissions"; +import type { DbUser } from "@/src/types/user"; type DbUserRow = DbUser & RowDataPacket; @@ -34,10 +35,6 @@ function authError(message: string, error: unknown, details?: Record { let rows: DbUserRow[]; @@ -66,7 +63,8 @@ async function findActiveUser(identifier: string): Promise { return null; } - if (!isUserRole(user.ruolo)) { + const ruolo = normalizeRole(user.ruolo); + if (!ruolo) { authLog("Utente trovato con ruolo non valido", { userId: user.id, ruolo: user.ruolo, @@ -77,10 +75,10 @@ async function findActiveUser(identifier: string): Promise { authLog("Utente attivo trovato", { userId: user.id, utente: user.utente, - ruolo: user.ruolo, + ruolo, }); - return user; + return { ...user, ruolo }; } async function updateLastLogin(userId: number): Promise { @@ -191,7 +189,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }, session({ session, token }) { session.user.id = token.id ?? ""; - session.user.ruolo = token.ruolo ?? "user"; + session.user.ruolo = token.ruolo ?? "operatore"; session.user.utente = token.utente ?? session.user.name ?? ""; return session; diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx new file mode 100644 index 0000000..dfaffdb --- /dev/null +++ b/src/components/AppShell.tsx @@ -0,0 +1,38 @@ +import { redirect } from "next/navigation"; + +import { auth } from "@/src/auth"; +import SignOutButton from "@/src/components/auth/SignOutButton"; +import Navbar from "@/src/components/Navbar"; +import { PermissionsProvider } from "@/src/contexts/PermissionsContext"; +import { StabilimentoProvider } from "@/src/contexts/StabilimentoContext"; +import { getPermissions, normalizeRole } from "@/src/lib/permissions"; + +export default async function AppShell({ children }: { children: React.ReactNode }) { + const session = await auth(); + + if (!session?.user) { + redirect("/login"); + } + + const ruolo = normalizeRole(session.user.ruolo); + if (!ruolo) { + redirect("/login"); + } + + const permissions = getPermissions(ruolo); + + return ( + + +
+ } + /> +
{children}
+
+
+
+ ); +} diff --git a/src/components/ArticleCard.tsx b/src/components/ArticleCard.tsx index 02fa1b5..2f466a0 100644 --- a/src/components/ArticleCard.tsx +++ b/src/components/ArticleCard.tsx @@ -11,6 +11,7 @@ interface ArticleCardProps { onEdit: (article: Article) => void; onDelete: (article: Article) => void; onMovimento?: (article: Article, tipo: TipoMovimento) => void; + allowScarico?: boolean; draggable?: boolean; } @@ -21,6 +22,7 @@ export default function ArticleCard({ onEdit, onDelete, onMovimento, + allowScarico = true, draggable = true, }: ArticleCardProps) { const [deleting, setDeleting] = useState(false); @@ -166,8 +168,9 @@ export default function ArticleCard({
+ {!readOnly && ( + <> +

+ Clicca sul pulsante + per aggiungere la prima macchina +

+ + + )} ) : (
@@ -515,7 +525,8 @@ export default function MacchineTree() { onCopy={handleCopy} onPaste={handlePaste} onShowArticles={handleShowArticles} - onDropArticle={handleDropArticle} + onDropArticle={readOnly ? undefined : handleDropArticle} + readOnly={readOnly} /> ))}
@@ -609,6 +620,7 @@ export default function MacchineTree() { macchinaNome={showArticlesModal.macchinaNome} macchinaStabilimentoId={showArticlesModal.macchinaStabilimentoId} articoli={showArticlesModal.articoli} + canRemove={permissions.canRemoveArticoloMacchina} onClose={() => setShowArticlesModal(null)} onArticoloRimosso={async () => { await fetchArticoliPerMacchina(); diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 6da4e0e..dd9c2ca 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -2,74 +2,88 @@ // File: Navbar.tsx // componente Navbar // @author: "villari.andrea@libero.it" -// @version: "1.0.0 2026-05-07" +// @version: "1.1.0 2026-05-31" //==================================== -"use client" -import { ReactNode} from "react"; -import Link from "next/link"; +"use client"; + +import { ReactNode } from "react"; +import Link from "next/link"; import Image from "next/image"; import GlobalStabilimentoSelector from "./GlobalStabilimentoSelector"; +import type { AppPermissions } from "@/src/lib/permissions"; interface NavLink { - href: string; - label: string; + href: string; + label: string; + visible: (p: AppPermissions) => boolean; } const navLinks: NavLink[] = [ - { href: "/", label: "Home"}, - { href: "/movimenti", label: "Movimenti"}, - { href: "/articoli", label: "Articoli"}, - { href: "/macchine", label: "Macchine"}, - { href: "/stabilimenti", label: "Stabilimenti"}, - { href: "/utenti", label: "Utenti"}, + { href: "/", label: "Home", visible: () => true }, + { href: "/movimenti", label: "Movimenti", visible: () => true }, + { href: "/articoli", label: "Articoli", visible: () => true }, + { href: "/macchine", label: "Macchine", visible: () => true }, + { + href: "/stabilimenti", + label: "Stabilimenti", + visible: (p) => p.canManageStabilimenti, + }, + { href: "/utenti", label: "Utenti", visible: (p) => p.canManageUtenti }, ]; -export default function Navbar({ actions }: { actions?: ReactNode }) { - return ( - + ); } diff --git a/src/components/TreeNode.tsx b/src/components/TreeNode.tsx index a7ecece..701783c 100644 --- a/src/components/TreeNode.tsx +++ b/src/components/TreeNode.tsx @@ -57,6 +57,7 @@ interface TreeNodeProps { onPaste: (targetId: number) => void; onShowArticles?: (nodeId: number) => void; onDropArticle?: (macchinaId: number, article: unknown) => void; + readOnly?: boolean; } export default function TreeNode({ @@ -74,6 +75,7 @@ export default function TreeNode({ onPaste, onShowArticles, onDropArticle, + readOnly = false, }: TreeNodeProps) { const [isExpanded, setIsExpanded] = useState(false); const [isDragOver, setIsDragOver] = useState(false); @@ -100,10 +102,11 @@ export default function TreeNode({ const totalQuantity = getTotalQuantity(node); const handleDrop = (e: DragEvent) => { + if (readOnly) return; e.preventDefault(); e.stopPropagation(); setIsDragOver(false); - + try { const data = e.dataTransfer.getData("application/json"); if (data && onDropArticle) { @@ -136,6 +139,7 @@ export default function TreeNode({ }; const handleDragEnter = (e: DragEvent) => { + if (readOnly) return; e.preventDefault(); e.stopPropagation(); setIsDragOver(true); @@ -171,10 +175,10 @@ export default function TreeNode({ ${getNodeTypeBg()} ${isDragOver ? "ring-2 ring-blue-500 bg-blue-900/50 scale-[1.02]" : ""} hover:bg-zinc-800/70`} - onDragEnter={handleDragEnter} - onDragOver={handleDragOver} - onDragLeave={handleDragLeave} - onDrop={handleDrop} + onDragEnter={readOnly ? undefined : handleDragEnter} + onDragOver={readOnly ? undefined : handleDragOver} + onDragLeave={readOnly ? undefined : handleDragLeave} + onDrop={readOnly ? undefined : handleDrop} > {children.length > 0 && ( + )} {isExpanded && children.length > 0 && ( @@ -311,6 +317,7 @@ export default function TreeNode({ onPaste={onPaste} onShowArticles={onShowArticles} onDropArticle={onDropArticle} + readOnly={readOnly} /> ))} diff --git a/src/components/UtentiManager.tsx b/src/components/UtentiManager.tsx new file mode 100644 index 0000000..08f965e --- /dev/null +++ b/src/components/UtentiManager.tsx @@ -0,0 +1,389 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import type { UserPublic, UserRole } from "@/src/types/user"; +import { ROLE_LABELS, USER_ROLES } from "@/src/lib/permissions"; + +function FormModal({ + title, + onClose, + onSubmit, + children, + submitLabel, +}: { + title: string; + onClose: () => void; + onSubmit: (e: React.FormEvent) => void; + children: React.ReactNode; + submitLabel: string; +}) { + return ( +
+
+
+

{title}

+
+ {children} +
+ + +
+
+
+
+ ); +} + +function formatLastLogin(value?: Date | string | null): string { + if (!value) return "Mai"; + const d = value instanceof Date ? value : new Date(value); + if (Number.isNaN(d.getTime())) return "—"; + return d.toLocaleString("it-IT", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function roleBadgeClass(ruolo: UserRole): string { + switch (ruolo) { + case "amministratore": + return "bg-purple-900/40 text-purple-300 border-purple-800"; + case "tecnico": + return "bg-blue-900/40 text-blue-300 border-blue-800"; + case "operatore": + return "bg-zinc-800 text-zinc-300 border-zinc-700"; + } +} + +export default function UtentiManager() { + const [utenti, setUtenti] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(""); + const [modal, setModal] = useState<"create" | "edit" | null>(null); + const [editing, setEditing] = useState(null); + + const [formUtente, setFormUtente] = useState(""); + const [formMail, setFormMail] = useState(""); + const [formRuolo, setFormRuolo] = useState("operatore"); + const [formAttivo, setFormAttivo] = useState<0 | 1>(1); + const [formPassword, setFormPassword] = useState(""); + + const loadData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/utenti"); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Errore nel caricamento"); + } + const data = await res.json(); + setUtenti(data.utenti || []); + } catch (err) { + setError(err instanceof Error ? err.message : "Errore sconosciuto"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + const resetForm = () => { + setFormUtente(""); + setFormMail(""); + setFormRuolo("operatore"); + setFormAttivo(1); + setFormPassword(""); + }; + + const openCreate = () => { + setEditing(null); + resetForm(); + setModal("create"); + }; + + const openEdit = (user: UserPublic) => { + setEditing(user); + setFormUtente(user.utente); + setFormMail(user.mail); + setFormRuolo(user.ruolo); + setFormAttivo(user.attivo); + setFormPassword(""); + setModal("edit"); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + try { + const url = modal === "edit" && editing ? `/api/utenti/${editing.id}` : "/api/utenti"; + const method = modal === "edit" ? "PUT" : "POST"; + const body: Record = { + utente: formUtente, + mail: formMail, + ruolo: formRuolo, + attivo: formAttivo, + }; + if (formPassword) { + body.password = formPassword; + } + + const res = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Operazione fallita"); + setModal(null); + await loadData(); + } catch (err) { + setError(err instanceof Error ? err.message : "Errore"); + } + }; + + const handleDelete = async (user: UserPublic) => { + if (!confirm(`Eliminare l'utente "${user.utente}"?`)) return; + setError(null); + try { + const res = await fetch(`/api/utenti/${user.id}`, { method: "DELETE" }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Eliminazione fallita"); + await loadData(); + } catch (err) { + setError(err instanceof Error ? err.message : "Errore"); + } + }; + + const filtered = utenti.filter( + (u) => + u.utente.toLowerCase().includes(search.toLowerCase()) || + u.mail.toLowerCase().includes(search.toLowerCase()) || + ROLE_LABELS[u.ruolo].toLowerCase().includes(search.toLowerCase()), + ); + + const attivi = utenti.filter((u) => u.attivo === 1).length; + + return ( +
+
+

Gestione utenti

+

+ Amministratori, tecnici e operatori con permessi differenziati +

+
+ +
+
+

Utenti totali

+

{utenti.length}

+
+
+

Attivi

+

{attivi}

+
+
+ + {error && ( +
+ {error} + +
+ )} + +
+ setSearch(e.target.value)} + placeholder="Cerca per nome, email o ruolo..." + className="w-full md:max-w-md px-4 py-2 rounded-lg bg-zinc-900 border border-zinc-800 text-white placeholder-zinc-500 focus:outline-none focus:border-blue-500" + /> + +
+ + {loading ? ( +
+ + + + + Caricamento... +
+ ) : filtered.length === 0 ? ( +
+

Nessun utente

+

+ {search ? "Nessun risultato per la ricerca" : "Aggiungi il primo utente"} +

+ {!search && ( + + )} +
+ ) : ( +
+ + + + + + + + + + + + + {filtered.map((user) => ( + + + + + + + + + ))} + +
UtenteEmailRuoloStatoUltimo accessoAzioni
{user.utente}{user.mail} + + {ROLE_LABELS[user.ruolo]} + + + {user.attivo === 1 ? ( + Attivo + ) : ( + Disattivo + )} + + {formatLastLogin(user.last_login)} + +
+ + +
+
+
+ )} + + {modal && ( + setModal(null)} + onSubmit={handleSubmit} + submitLabel={modal === "edit" ? "Salva" : "Crea"} + > +
+ + setFormUtente(e.target.value)} + required + className="w-full px-3 py-2 rounded-lg bg-zinc-950 border border-zinc-700 text-white focus:outline-none focus:border-blue-500" + /> +
+
+ + setFormMail(e.target.value)} + required + className="w-full px-3 py-2 rounded-lg bg-zinc-950 border border-zinc-700 text-white focus:outline-none focus:border-blue-500" + /> +
+
+ + +
+
+ + +
+
+ + setFormPassword(e.target.value)} + required={modal === "create"} + minLength={6} + className="w-full px-3 py-2 rounded-lg bg-zinc-950 border border-zinc-700 text-white focus:outline-none focus:border-blue-500" + /> +
+
+ )} +
+ ); +} diff --git a/src/contexts/PermissionsContext.tsx b/src/contexts/PermissionsContext.tsx new file mode 100644 index 0000000..ca160e3 --- /dev/null +++ b/src/contexts/PermissionsContext.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { createContext, useContext, type ReactNode } from "react"; + +import type { AppPermissions } from "@/src/lib/permissions"; +import type { UserRole } from "@/src/types/user"; + +interface PermissionsContextValue { + ruolo: UserRole; + permissions: AppPermissions; +} + +const PermissionsContext = createContext(null); + +export function PermissionsProvider({ + ruolo, + permissions, + children, +}: { + ruolo: UserRole; + permissions: AppPermissions; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function usePermissions(): PermissionsContextValue { + const ctx = useContext(PermissionsContext); + if (!ctx) { + throw new Error("usePermissions deve essere usato dentro PermissionsProvider"); + } + return ctx; +} diff --git a/src/lib/auth/requireSession.ts b/src/lib/auth/requireSession.ts new file mode 100644 index 0000000..859df1f --- /dev/null +++ b/src/lib/auth/requireSession.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server"; + +import { auth } from "@/src/auth"; +import { + getPermissions, + normalizeRole, + type AppPermissions, +} from "@/src/lib/permissions"; +import type { UserRole } from "@/src/types/user"; + +export type AuthSession = { + user: { + id: string; + ruolo: UserRole; + utente: string; + name?: string | null; + email?: string | null; + }; + permissions: AppPermissions; +}; + +export async function requireSession(): Promise { + const session = await auth(); + + if (!session?.user) { + return NextResponse.json({ error: "Non autorizzato" }, { status: 401 }); + } + + const ruolo = normalizeRole(session.user.ruolo); + if (!ruolo) { + return NextResponse.json({ error: "Ruolo utente non valido" }, { status: 403 }); + } + + return { + user: { + id: session.user.id, + ruolo, + utente: session.user.utente, + name: session.user.name, + email: session.user.email, + }, + permissions: getPermissions(ruolo), + }; +} + +export function forbid(message = "Accesso negato"): NextResponse { + return NextResponse.json({ error: message }, { status: 403 }); +} + +export async function requirePermission( + check: (permissions: AppPermissions) => boolean, + message = "Accesso negato", +): Promise { + const result = await requireSession(); + if (result instanceof NextResponse) return result; + + if (!check(result.permissions)) { + return forbid(message); + } + + return result; +} diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts new file mode 100644 index 0000000..84c197a --- /dev/null +++ b/src/lib/permissions.ts @@ -0,0 +1,72 @@ +import type { UserRole } from "@/src/types/user"; + +/** Ruoli canonici in italiano (valori consigliati in DB). */ +export const USER_ROLES: UserRole[] = ["amministratore", "tecnico", "operatore"]; + +/** Mappatura ruoli legacy → ruoli attuali. */ +const LEGACY_ROLE_MAP: Record = { + admin: "amministratore", + sviluppo: "amministratore", + manager: "tecnico", + user: "operatore", + amministratore: "amministratore", + tecnico: "tecnico", + operatore: "operatore", +}; + +export const ROLE_LABELS: Record = { + amministratore: "Amministratore", + tecnico: "Tecnico", + operatore: "Operatore", +}; + +export function isUserRole(value: string): value is UserRole { + return USER_ROLES.includes(value as UserRole); +} + +export function normalizeRole(value: string): UserRole | null { + const key = value.trim().toLowerCase(); + const mapped = LEGACY_ROLE_MAP[key]; + return mapped ?? null; +} + +export interface AppPermissions { + canManageUtenti: boolean; + canManageStabilimenti: boolean; + canScaricoArticoli: boolean; + canAssegnaArticoliMacchina: boolean; + canEditMacchine: boolean; + canRemoveArticoloMacchina: boolean; +} + +export function getPermissions(ruolo: UserRole): AppPermissions { + switch (ruolo) { + case "amministratore": + return { + canManageUtenti: true, + canManageStabilimenti: true, + canScaricoArticoli: true, + canAssegnaArticoliMacchina: true, + canEditMacchine: true, + canRemoveArticoloMacchina: true, + }; + case "tecnico": + return { + canManageUtenti: false, + canManageStabilimenti: false, + canScaricoArticoli: true, + canAssegnaArticoliMacchina: true, + canEditMacchine: true, + canRemoveArticoloMacchina: true, + }; + case "operatore": + return { + canManageUtenti: false, + canManageStabilimenti: false, + canScaricoArticoli: false, + canAssegnaArticoliMacchina: false, + canEditMacchine: false, + canRemoveArticoloMacchina: false, + }; + } +} diff --git a/src/lib/users/index.ts b/src/lib/users/index.ts new file mode 100644 index 0000000..80f7746 --- /dev/null +++ b/src/lib/users/index.ts @@ -0,0 +1,187 @@ +import bcrypt from "bcrypt"; +import type { ResultSetHeader, RowDataPacket } from "mysql2"; + +import { db } from "@/src/lib/db"; +import { isUserRole } from "@/src/lib/permissions"; +import type { DbUser, UserInput, UserPublic, UserRole } from "@/src/types/user"; + +type UserRow = DbUser & RowDataPacket; + +const BCRYPT_ROUNDS = 10; + +function toPublic(row: UserRow): UserPublic { + return { + id: row.id, + utente: row.utente, + mail: row.mail, + ruolo: row.ruolo, + attivo: row.attivo, + last_login: row.last_login, + }; +} + +function cleanText(value: unknown): string { + return String(value ?? "").trim(); +} + +function parseUserInput(input: Partial, requirePassword: boolean): UserInput { + const utente = cleanText(input.utente); + const mail = cleanText(input.mail); + const ruolo = cleanText(input.ruolo) as UserRole; + const attivo = input.attivo === 0 || input.attivo === 1 ? input.attivo : 1; + const password = input.password ? String(input.password) : ""; + + if (!utente) { + throw new Error("Il nome utente è obbligatorio."); + } + if (!mail || !mail.includes("@")) { + throw new Error("Inserire un indirizzo email valido."); + } + if (!isUserRole(ruolo)) { + throw new Error("Ruolo non valido. Scegli Amministratore, Tecnico o Operatore."); + } + if (requirePassword && password.length < 6) { + throw new Error("La password deve avere almeno 6 caratteri."); + } + if (!requirePassword && password && password.length < 6) { + throw new Error("La password deve avere almeno 6 caratteri."); + } + + return { utente, mail, ruolo, attivo, password: password || undefined }; +} + +export async function listUsers(): Promise { + const [rows] = await db.execute( + `SELECT id, utente, mail, ruolo, attivo, last_login + FROM utenti + ORDER BY utente ASC`, + ); + + return rows.map(toPublic); +} + +export async function getUserById(id: number): Promise { + const [rows] = await db.execute( + `SELECT id, utente, mail, ruolo, attivo, last_login + FROM utenti WHERE id = :id LIMIT 1`, + { id }, + ); + + const row = rows[0]; + return row ? toPublic(row) : null; +} + +async function assertUniqueUtenteMail( + utente: string, + mail: string, + excludeId?: number, +): Promise { + const params: Record = { utente, mail }; + let query = ` + SELECT id FROM utenti + WHERE (utente = :utente OR mail = :mail) + `; + if (excludeId) { + query += " AND id != :excludeId"; + params.excludeId = excludeId; + } + query += " LIMIT 1"; + + const [rows] = await db.execute(query, params); + if (rows[0]) { + throw new Error("Nome utente o email già in uso."); + } +} + +export async function createUser(input: Partial): Promise { + const parsed = parseUserInput(input, true); + await assertUniqueUtenteMail(parsed.utente, parsed.mail); + + const hash = await bcrypt.hash(parsed.password!, BCRYPT_ROUNDS); + + const [result] = await db.execute( + `INSERT INTO utenti (utente, mail, password, ruolo, attivo) + VALUES (:utente, :mail, :password, :ruolo, :attivo)`, + { + utente: parsed.utente, + mail: parsed.mail, + password: hash, + ruolo: parsed.ruolo, + attivo: parsed.attivo, + }, + ); + + const created = await getUserById(result.insertId); + if (!created) { + throw new Error("Errore nella creazione dell'utente."); + } + return created; +} + +export async function updateUser( + id: number, + input: Partial, +): Promise { + const existing = await getUserById(id); + if (!existing) { + throw new Error("Utente non trovato."); + } + + const parsed = parseUserInput( + { + utente: input.utente ?? existing.utente, + mail: input.mail ?? existing.mail, + ruolo: input.ruolo ?? existing.ruolo, + attivo: input.attivo ?? existing.attivo, + password: input.password, + }, + false, + ); + + await assertUniqueUtenteMail(parsed.utente, parsed.mail, id); + + if (parsed.password) { + const hash = await bcrypt.hash(parsed.password, BCRYPT_ROUNDS); + await db.execute( + `UPDATE utenti + SET utente = :utente, mail = :mail, ruolo = :ruolo, attivo = :attivo, password = :password + WHERE id = :id`, + { + utente: parsed.utente, + mail: parsed.mail, + ruolo: parsed.ruolo, + attivo: parsed.attivo, + password: hash, + id, + }, + ); + } else { + await db.execute( + `UPDATE utenti + SET utente = :utente, mail = :mail, ruolo = :ruolo, attivo = :attivo + WHERE id = :id`, + { + utente: parsed.utente, + mail: parsed.mail, + ruolo: parsed.ruolo, + attivo: parsed.attivo, + id, + }, + ); + } + + const updated = await getUserById(id); + if (!updated) { + throw new Error("Errore nell'aggiornamento dell'utente."); + } + return updated; +} + +export async function deleteUser(id: number): Promise { + const existing = await getUserById(id); + if (!existing) { + throw new Error("Utente non trovato."); + } + + await db.execute("DELETE FROM utenti WHERE id = :id", { id }); +} diff --git a/src/types/user.ts b/src/types/user.ts index 52bbe49..5ed0192 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -1,4 +1,4 @@ -export type UserRole = "admin" | "manager" | "user" | "sviluppo"; +export type UserRole = "amministratore" | "tecnico" | "operatore"; export interface DbUser { id: number; @@ -9,3 +9,21 @@ export interface DbUser { attivo: 0 | 1; last_login?: Date | string | null; } + +/** Utente esposto dalle API (senza password). */ +export interface UserPublic { + id: number; + utente: string; + mail: string; + ruolo: UserRole; + attivo: 0 | 1; + last_login?: Date | string | null; +} + +export interface UserInput { + utente: string; + mail: string; + ruolo: UserRole; + attivo: 0 | 1; + password?: string; +}