inserita la gestione degli utenti
All checks were successful
Deploy to VPS / build (push) Successful in 40s
Deploy to VPS / deploy (push) Successful in 29s

This commit is contained in:
AV77web 2026-05-31 17:41:45 +02:00
parent bdb3145bd6
commit b7fd15ffcd
29 changed files with 1145 additions and 216 deletions

View file

@ -1,28 +1,5 @@
import { redirect } from "next/navigation"; import AppShell from "@/src/components/AppShell";
import { auth } from "@/src/auth"; export default function MainLayout({ children }: { children: React.ReactNode }) {
import SignOutButton from "@/src/components/auth/SignOutButton"; return <AppShell>{children}</AppShell>;
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 (
<StabilimentoProvider>
<div>
<Navbar actions={<SignOutButton />} />
<main>{children}</main>
</div>
</StabilimentoProvider>
);
} }

View file

@ -1,27 +1,5 @@
import { redirect } from "next/navigation"; import AppShell from "@/src/components/AppShell";
import { auth } from "@/src/auth"; export default function ManagementLayout({ children }: { children: React.ReactNode }) {
import SignOutButton from "@/src/components/auth/SignOutButton"; return <AppShell>{children}</AppShell>;
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 (
<StabilimentoProvider>
<div>
<Navbar actions={<SignOutButton />} />
<main>{children}</main>
</div>
</StabilimentoProvider>
);
} }

View file

@ -1,8 +1,20 @@
"use client"; import { redirect } from "next/navigation";
import { auth } from "@/src/auth";
import StabilimentiManager from "@/src/components/StabilimentiManager"; 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 ( return (
<div className="min-h-screen bg-black -mx-4 -my-6 px-4 py-6"> <div className="min-h-screen bg-black -mx-4 -my-6 px-4 py-6">
<StabilimentiManager /> <StabilimentiManager />

View file

@ -1,8 +1,23 @@
import Link from "next/link"; import { redirect } from "next/navigation";
export default function Utenti() { import { auth } from "@/src/auth";
return <> import UtentiManager from "@/src/components/UtentiManager";
<h1>Utenti</h1> import { getPermissions, normalizeRole } from "@/src/lib/permissions";
<Link href="/utenti">Utenti</Link>
</> 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 (
<div className="min-h-screen bg-black -mx-4 -my-6 px-4 py-6">
<UtentiManager />
</div>
);
}

View file

@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/src/auth"; import { requirePermission } from "@/src/lib/auth/requireSession";
import { rimuoviArticoloDaMacchina } from "@/src/lib/artMacchineParti"; import { rimuoviArticoloDaMacchina } from "@/src/lib/artMacchineParti";
interface RouteParams { interface RouteParams {
@ -8,11 +8,8 @@ interface RouteParams {
} }
export async function DELETE(request: Request, { params }: RouteParams) { export async function DELETE(request: Request, { params }: RouteParams) {
const session = await auth(); const session = await requirePermission((p) => p.canRemoveArticoloMacchina);
if (session instanceof NextResponse) return session;
if (!session?.user) {
return NextResponse.json({ error: "Non autorizzato" }, { status: 401 });
}
const { idmacchina, idart } = await params; const { idmacchina, idart } = await params;
const macchinaId = parseInt(idmacchina, 10); const macchinaId = parseInt(idmacchina, 10);

View file

@ -1,14 +1,11 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/src/auth"; import { requirePermission } from "@/src/lib/auth/requireSession";
import { assegnaArticoloMacchina } from "@/src/lib/artMacchineParti"; import { assegnaArticoloMacchina } from "@/src/lib/artMacchineParti";
export async function POST(request: Request) { export async function POST(request: Request) {
const session = await auth(); const session = await requirePermission((p) => p.canAssegnaArticoliMacchina);
if (session instanceof NextResponse) return session;
if (!session?.user) {
return NextResponse.json({ error: "Non autorizzato" }, { status: 401 });
}
try { try {
const body = await request.json(); const body = await request.json();

View file

@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/src/auth"; import { auth } from "@/src/auth";
import { requirePermission } from "@/src/lib/auth/requireSession";
import { import {
getMacchinaById, getMacchinaById,
updateMacchina, updateMacchina,
@ -47,11 +48,8 @@ export async function GET(request: Request, { params }: RouteParams) {
} }
export async function PUT(request: Request, { params }: RouteParams) { export async function PUT(request: Request, { params }: RouteParams) {
const session = await auth(); const session = await requirePermission((p) => p.canEditMacchine);
if (session instanceof NextResponse) return session;
if (!session?.user) {
return NextResponse.json({ error: "Non autorizzato" }, { status: 401 });
}
const { id } = await params; const { id } = await params;
const macchinaId = parseInt(id, 10); 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 // 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); const deleted = await deleteMacchina(macchinaId, idMagazzinoRientro, user);
if (!deleted) { if (!deleted) {

View file

@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/src/auth"; import { auth } from "@/src/auth";
import { requirePermission } from "@/src/lib/auth/requireSession";
import { import {
listMacchine, listMacchine,
createMacchina, createMacchina,
@ -28,11 +29,8 @@ export async function GET() {
} }
export async function POST(request: Request) { export async function POST(request: Request) {
const session = await auth(); const session = await requirePermission((p) => p.canEditMacchine);
if (session instanceof NextResponse) return session;
if (!session?.user) {
return NextResponse.json({ error: "Non autorizzato" }, { status: 401 });
}
try { try {
const body = await request.json(); const body = await request.json();

View file

@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/src/auth"; import { auth } from "@/src/auth";
import { requirePermission } from "@/src/lib/auth/requireSession";
import { getMagazzinoById, updateMagazzino, deleteMagazzino } from "@/src/lib/stabilimenti"; import { getMagazzinoById, updateMagazzino, deleteMagazzino } from "@/src/lib/stabilimenti";
interface RouteContext { interface RouteContext {
@ -40,11 +41,8 @@ export async function GET(_request: Request, context: RouteContext) {
} }
export async function PUT(request: Request, context: RouteContext) { export async function PUT(request: Request, context: RouteContext) {
const session = await auth(); const session = await requirePermission((p) => p.canManageStabilimenti);
if (session instanceof NextResponse) return session;
if (!session?.user) {
return NextResponse.json({ error: "Non autorizzato" }, { status: 401 });
}
try { try {
const magazzino = await updateMagazzino( const magazzino = await updateMagazzino(
@ -61,11 +59,8 @@ export async function PUT(request: Request, context: RouteContext) {
} }
export async function DELETE(_request: Request, context: RouteContext) { export async function DELETE(_request: Request, context: RouteContext) {
const session = await auth(); const session = await requirePermission((p) => p.canManageStabilimenti);
if (session instanceof NextResponse) return session;
if (!session?.user) {
return NextResponse.json({ error: "Non autorizzato" }, { status: 401 });
}
try { try {
await deleteMagazzino(await readId(context.params)); await deleteMagazzino(await readId(context.params));

View file

@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/src/auth"; import { auth } from "@/src/auth";
import { requirePermission } from "@/src/lib/auth/requireSession";
import { listMagazzini, createMagazzino } from "@/src/lib/stabilimenti"; import { listMagazzini, createMagazzino } from "@/src/lib/stabilimenti";
export async function GET(request: Request) { export async function GET(request: Request) {
@ -29,11 +30,8 @@ export async function GET(request: Request) {
} }
export async function POST(request: Request) { export async function POST(request: Request) {
const session = await auth(); const session = await requirePermission((p) => p.canManageStabilimenti);
if (session instanceof NextResponse) return session;
if (!session?.user) {
return NextResponse.json({ error: "Non autorizzato" }, { status: 401 });
}
try { try {
const magazzino = await createMagazzino(await request.json()); const magazzino = await createMagazzino(await request.json());

View file

@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/src/auth"; import { auth } from "@/src/auth";
import { forbid, requireSession } from "@/src/lib/auth/requireSession";
import { listMovimenti, createMovimento } from "@/src/lib/movimenti"; import { listMovimenti, createMovimento } from "@/src/lib/movimenti";
import { getArticleById } from "@/src/lib/articles"; import { getArticleById } from "@/src/lib/articles";
import type { TipoMovimento } from "@/src/types/movimento"; import type { TipoMovimento } from "@/src/types/movimento";
@ -35,11 +36,8 @@ export async function GET(request: Request) {
} }
export async function POST(request: Request) { export async function POST(request: Request) {
const session = await auth(); const session = await requireSession();
if (session instanceof NextResponse) return session;
if (!session?.user) {
return NextResponse.json({ error: "Non autorizzato" }, { status: 401 });
}
try { try {
const body = await request.json(); 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); const articolo = await getArticleById(id_articolo);
if (!articolo) { if (!articolo) {
return NextResponse.json( return NextResponse.json(
@ -81,7 +83,7 @@ export async function POST(request: Request) {
} }
const tipoMovimentoDB = tipo === "carico" ? 1 : 2; 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({ const movimentoId = await createMovimento({
id_articolo, id_articolo,

View file

@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/src/auth"; import { auth } from "@/src/auth";
import { requirePermission } from "@/src/lib/auth/requireSession";
import { import {
getStabilimentoById, getStabilimentoById,
updateStabilimento, updateStabilimento,
@ -44,11 +45,8 @@ export async function GET(_request: Request, context: RouteContext) {
} }
export async function PUT(request: Request, context: RouteContext) { export async function PUT(request: Request, context: RouteContext) {
const session = await auth(); const session = await requirePermission((p) => p.canManageStabilimenti);
if (session instanceof NextResponse) return session;
if (!session?.user) {
return NextResponse.json({ error: "Non autorizzato" }, { status: 401 });
}
try { try {
const stabilimento = await updateStabilimento( const stabilimento = await updateStabilimento(
@ -65,11 +63,8 @@ export async function PUT(request: Request, context: RouteContext) {
} }
export async function DELETE(_request: Request, context: RouteContext) { export async function DELETE(_request: Request, context: RouteContext) {
const session = await auth(); const session = await requirePermission((p) => p.canManageStabilimenti);
if (session instanceof NextResponse) return session;
if (!session?.user) {
return NextResponse.json({ error: "Non autorizzato" }, { status: 401 });
}
try { try {
await deleteStabilimento(await readId(context.params)); await deleteStabilimento(await readId(context.params));

View file

@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/src/auth"; import { auth } from "@/src/auth";
import { requirePermission } from "@/src/lib/auth/requireSession";
import { import {
listStabilimenti, listStabilimenti,
getStabilimentiWithMagazzini, getStabilimentiWithMagazzini,
@ -35,11 +36,8 @@ export async function GET(request: Request) {
} }
export async function POST(request: Request) { export async function POST(request: Request) {
const session = await auth(); const session = await requirePermission((p) => p.canManageStabilimenti);
if (session instanceof NextResponse) return session;
if (!session?.user) {
return NextResponse.json({ error: "Non autorizzato" }, { status: 401 });
}
try { try {
const stabilimento = await createStabilimento(await request.json()); const stabilimento = await createStabilimento(await request.json());

View file

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

View file

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

View file

@ -4,7 +4,8 @@ import Credentials from "next-auth/providers/credentials";
import type { ResultSetHeader, RowDataPacket } from "mysql2"; import type { ResultSetHeader, RowDataPacket } from "mysql2";
import { db } from "@/src/lib/db"; 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; type DbUserRow = DbUser & RowDataPacket;
@ -34,10 +35,6 @@ function authError(message: string, error: unknown, details?: Record<string, unk
}); });
} }
function isUserRole(value: string): value is UserRole {
return ["admin", "manager", "user", "sviluppo"].includes(value);
}
async function findActiveUser(identifier: string): Promise<DbUser | null> { async function findActiveUser(identifier: string): Promise<DbUser | null> {
let rows: DbUserRow[]; let rows: DbUserRow[];
@ -66,7 +63,8 @@ async function findActiveUser(identifier: string): Promise<DbUser | null> {
return null; return null;
} }
if (!isUserRole(user.ruolo)) { const ruolo = normalizeRole(user.ruolo);
if (!ruolo) {
authLog("Utente trovato con ruolo non valido", { authLog("Utente trovato con ruolo non valido", {
userId: user.id, userId: user.id,
ruolo: user.ruolo, ruolo: user.ruolo,
@ -77,10 +75,10 @@ async function findActiveUser(identifier: string): Promise<DbUser | null> {
authLog("Utente attivo trovato", { authLog("Utente attivo trovato", {
userId: user.id, userId: user.id,
utente: user.utente, utente: user.utente,
ruolo: user.ruolo, ruolo,
}); });
return user; return { ...user, ruolo };
} }
async function updateLastLogin(userId: number): Promise<void> { async function updateLastLogin(userId: number): Promise<void> {
@ -191,7 +189,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
}, },
session({ session, token }) { session({ session, token }) {
session.user.id = token.id ?? ""; 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 ?? ""; session.user.utente = token.utente ?? session.user.name ?? "";
return session; return session;

View file

@ -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 (
<PermissionsProvider ruolo={ruolo} permissions={permissions}>
<StabilimentoProvider>
<div>
<Navbar
permissions={permissions}
userLabel={session.user.utente}
actions={<SignOutButton />}
/>
<main>{children}</main>
</div>
</StabilimentoProvider>
</PermissionsProvider>
);
}

View file

@ -11,6 +11,7 @@ interface ArticleCardProps {
onEdit: (article: Article) => void; onEdit: (article: Article) => void;
onDelete: (article: Article) => void; onDelete: (article: Article) => void;
onMovimento?: (article: Article, tipo: TipoMovimento) => void; onMovimento?: (article: Article, tipo: TipoMovimento) => void;
allowScarico?: boolean;
draggable?: boolean; draggable?: boolean;
} }
@ -21,6 +22,7 @@ export default function ArticleCard({
onEdit, onEdit,
onDelete, onDelete,
onMovimento, onMovimento,
allowScarico = true,
draggable = true, draggable = true,
}: ArticleCardProps) { }: ArticleCardProps) {
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
@ -166,8 +168,9 @@ export default function ArticleCard({
</button> </button>
<button <button
onClick={() => onMovimento(article, "scarico")} onClick={() => onMovimento(article, "scarico")}
className="p-1.5 rounded text-zinc-500 hover:text-red-400 hover:bg-red-500/10 transition-colors" disabled={!allowScarico}
title="Scarico" className="p-1.5 rounded text-zinc-500 hover:text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:text-zinc-500 disabled:hover:bg-transparent"
title={allowScarico ? "Scarico" : "Scarico non consentito per il tuo ruolo"}
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />

View file

@ -7,9 +7,12 @@ import type { TipoMovimento } from "@/src/types/movimento";
import ArticleCard from "./ArticleCard"; import ArticleCard from "./ArticleCard";
import ArticleModal from "./ArticleModal"; import ArticleModal from "./ArticleModal";
import MovimentoModal from "./MovimentoModal"; import MovimentoModal from "./MovimentoModal";
import { usePermissions } from "@/src/contexts/PermissionsContext";
import { useStabilimento } from "@/src/contexts/StabilimentoContext"; import { useStabilimento } from "@/src/contexts/StabilimentoContext";
export default function ArticlesList() { export default function ArticlesList() {
const { permissions } = usePermissions();
const [articles, setArticles] = useState<Article[]>([]); const [articles, setArticles] = useState<Article[]>([]);
const [families, setFamilies] = useState<ArticleFamily[]>([]); const [families, setFamilies] = useState<ArticleFamily[]>([]);
const [giacenze, setGiacenze] = useState<GiacenzePerArticolo>({}); const [giacenze, setGiacenze] = useState<GiacenzePerArticolo>({});
@ -211,6 +214,8 @@ export default function ArticlesList() {
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
onMovimento={handleMovimento} onMovimento={handleMovimento}
allowScarico={permissions.canScaricoArticoli}
draggable={permissions.canAssegnaArticoliMacchina}
/> />
)) ))
)} )}

View file

@ -9,6 +9,7 @@ interface ArticoliMacchinaModalProps {
macchinaNome: string; macchinaNome: string;
macchinaStabilimentoId: number | null; macchinaStabilimentoId: number | null;
articoli: ArticoloAssegnatoMacchina[]; articoli: ArticoloAssegnatoMacchina[];
canRemove?: boolean;
onClose: () => void; onClose: () => void;
onArticoloRimosso: () => void; onArticoloRimosso: () => void;
} }
@ -26,6 +27,7 @@ export default function ArticoliMacchinaModal({
macchinaNome, macchinaNome,
macchinaStabilimentoId, macchinaStabilimentoId,
articoli, articoli,
canRemove = true,
onClose, onClose,
onArticoloRimosso, onArticoloRimosso,
}: ArticoliMacchinaModalProps) { }: ArticoliMacchinaModalProps) {
@ -192,8 +194,9 @@ export default function ArticoliMacchinaModal({
</div> </div>
<button <button
onClick={() => handleRimuoviClick(art)} onClick={() => handleRimuoviClick(art)}
className="p-2 rounded-lg text-zinc-500 hover:text-red-400 hover:bg-red-900/20 opacity-0 group-hover:opacity-100 transition-opacity" disabled={!canRemove}
title="Rimuovi articolo" className="p-2 rounded-lg text-zinc-500 hover:text-red-400 hover:bg-red-900/20 opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:text-zinc-500 disabled:hover:bg-transparent"
title={canRemove ? "Rimuovi articolo" : "Rimozione non consentita per il tuo ruolo"}
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />

View file

@ -6,6 +6,7 @@ import type { ArticoloAssegnatoMacchina } from "@/src/types/artMacchineParti";
import TreeNode from "./TreeNode"; import TreeNode from "./TreeNode";
import MacchinaModal from "./MacchinaModal"; import MacchinaModal from "./MacchinaModal";
import ArticoliMacchinaModal from "./ArticoliMacchinaModal"; import ArticoliMacchinaModal from "./ArticoliMacchinaModal";
import { usePermissions } from "@/src/contexts/PermissionsContext";
import { useStabilimento } from "@/src/contexts/StabilimentoContext"; import { useStabilimento } from "@/src/contexts/StabilimentoContext";
interface DroppedArticle { interface DroppedArticle {
@ -24,6 +25,9 @@ interface DropModalState {
} }
export default function MacchineTree() { export default function MacchineTree() {
const { permissions } = usePermissions();
const readOnly = !permissions.canEditMacchine;
const [nodes, setNodes] = useState<MacchinaWithDetails[]>([]); const [nodes, setNodes] = useState<MacchinaWithDetails[]>([]);
const [articlesByMacchina, setArticlesByMacchina] = useState<Record<number, ArticoloAssegnatoMacchina[]>>({}); const [articlesByMacchina, setArticlesByMacchina] = useState<Record<number, ArticoloAssegnatoMacchina[]>>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -424,16 +428,18 @@ export default function MacchineTree() {
{filteredCount} elementi in {selectedStabilimento.descrizione} {filteredCount} elementi in {selectedStabilimento.descrizione}
</p> </p>
</div> </div>
<button {!readOnly && (
onClick={handleAddMachine} <button
disabled={isCloning} onClick={handleAddMachine}
className="p-2 rounded-lg bg-blue-600 text-white hover:bg-blue-500 transition-colors disabled:opacity-50" disabled={isCloning}
title="Nuova macchina" className="p-2 rounded-lg bg-blue-600 text-white hover:bg-blue-500 transition-colors disabled:opacity-50"
> title="Nuova macchina"
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</svg> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</button> </svg>
</button>
)}
</div> </div>
{/* Indicatore clonazione */} {/* Indicatore clonazione */}
@ -448,7 +454,7 @@ export default function MacchineTree() {
)} )}
{/* Indicatore copia */} {/* Indicatore copia */}
{copiedNodeId && ( {!readOnly && copiedNodeId && (
<div className="mb-3 px-3 py-2 rounded-lg bg-yellow-900/30 border border-yellow-700/50 flex items-center justify-between"> <div className="mb-3 px-3 py-2 rounded-lg bg-yellow-900/30 border border-yellow-700/50 flex items-center justify-between">
<span className="text-sm text-yellow-300"> <span className="text-sm text-yellow-300">
Nodo copiato. Clicca su "Incolla" per inserirlo. Nodo copiato. Clicca su "Incolla" per inserirlo.
@ -487,15 +493,19 @@ export default function MacchineTree() {
<p className="text-zinc-500 text-sm mb-2"> <p className="text-zinc-500 text-sm mb-2">
Nessuna macchina in &quot;{selectedStabilimento.descrizione}&quot; Nessuna macchina in &quot;{selectedStabilimento.descrizione}&quot;
</p> </p>
<p className="text-zinc-600 text-xs mb-4"> {!readOnly && (
Clicca sul pulsante + per aggiungere la prima macchina <>
</p> <p className="text-zinc-600 text-xs mb-4">
<button Clicca sul pulsante + per aggiungere la prima macchina
onClick={handleAddMachine} </p>
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-500 transition-colors" <button
> onClick={handleAddMachine}
Aggiungi macchina className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-500 transition-colors"
</button> >
Aggiungi macchina
</button>
</>
)}
</div> </div>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
@ -515,7 +525,8 @@ export default function MacchineTree() {
onCopy={handleCopy} onCopy={handleCopy}
onPaste={handlePaste} onPaste={handlePaste}
onShowArticles={handleShowArticles} onShowArticles={handleShowArticles}
onDropArticle={handleDropArticle} onDropArticle={readOnly ? undefined : handleDropArticle}
readOnly={readOnly}
/> />
))} ))}
</div> </div>
@ -609,6 +620,7 @@ export default function MacchineTree() {
macchinaNome={showArticlesModal.macchinaNome} macchinaNome={showArticlesModal.macchinaNome}
macchinaStabilimentoId={showArticlesModal.macchinaStabilimentoId} macchinaStabilimentoId={showArticlesModal.macchinaStabilimentoId}
articoli={showArticlesModal.articoli} articoli={showArticlesModal.articoli}
canRemove={permissions.canRemoveArticoloMacchina}
onClose={() => setShowArticlesModal(null)} onClose={() => setShowArticlesModal(null)}
onArticoloRimosso={async () => { onArticoloRimosso={async () => {
await fetchArticoliPerMacchina(); await fetchArticoliPerMacchina();

View file

@ -2,74 +2,88 @@
// File: Navbar.tsx // File: Navbar.tsx
// componente Navbar // componente Navbar
// @author: "villari.andrea@libero.it" // @author: "villari.andrea@libero.it"
// @version: "1.0.0 2026-05-07" // @version: "1.1.0 2026-05-31"
//==================================== //====================================
"use client" "use client";
import { ReactNode} from "react";
import Link from "next/link"; import { ReactNode } from "react";
import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import GlobalStabilimentoSelector from "./GlobalStabilimentoSelector"; import GlobalStabilimentoSelector from "./GlobalStabilimentoSelector";
import type { AppPermissions } from "@/src/lib/permissions";
interface NavLink { interface NavLink {
href: string; href: string;
label: string; label: string;
visible: (p: AppPermissions) => boolean;
} }
const navLinks: NavLink[] = [ const navLinks: NavLink[] = [
{ href: "/", label: "Home"}, { href: "/", label: "Home", visible: () => true },
{ href: "/movimenti", label: "Movimenti"}, { href: "/movimenti", label: "Movimenti", visible: () => true },
{ href: "/articoli", label: "Articoli"}, { href: "/articoli", label: "Articoli", visible: () => true },
{ href: "/macchine", label: "Macchine"}, { href: "/macchine", label: "Macchine", visible: () => true },
{ href: "/stabilimenti", label: "Stabilimenti"}, {
{ href: "/utenti", label: "Utenti"}, href: "/stabilimenti",
label: "Stabilimenti",
visible: (p) => p.canManageStabilimenti,
},
{ href: "/utenti", label: "Utenti", visible: (p) => p.canManageUtenti },
]; ];
export default function Navbar({ actions }: { actions?: ReactNode }) { export default function Navbar({
return ( actions,
<nav className="border-b border-zinc-800 bg-black"> permissions,
<div className="max-w-7xl mx-auto px-4"> userLabel,
<div className="flex items-center justify-between h-32"> }: {
{/* Logo */} actions?: ReactNode;
<Link permissions: AppPermissions;
href="/" userLabel?: string;
className="flex items-center hover:opacity-80 transition-opacity" }) {
> const visibleLinks = navLinks.filter((link) => link.visible(permissions));
<Image
src="/immagini/Logo.webp"
alt="MagRicambi"
width={480}
height={120}
priority
className="h-28 w-auto"
/>
</Link>
{/* Navigation Links */} return (
<div className="flex items-center space-x-6"> <nav className="border-b border-zinc-800 bg-black">
{navLinks.map((link) => ( <div className="max-w-7xl mx-auto px-4">
<Link <div className="flex items-center justify-between h-32">
key={link.href} <Link
href={link.href} href="/"
className="text-sm text-zinc-300 hover:text-zinc-100 transition-colors" className="flex items-center hover:opacity-80 transition-opacity"
> >
{link.label} <Image
</Link> src="/immagini/Logo.webp"
))} alt="MagRicambi"
</div> width={480}
height={120}
priority
className="h-28 w-auto"
/>
</Link>
{/* Stabilimento Selector */} <div className="flex items-center space-x-6">
<div className="flex-1 flex justify-center px-4"> {visibleLinks.map((link) => (
<GlobalStabilimentoSelector /> <Link
</div> key={link.href}
href={link.href}
className="text-sm text-zinc-300 hover:text-zinc-100 transition-colors"
>
{link.label}
</Link>
))}
</div>
{/* Actions */} <div className="flex-1 flex justify-center px-4">
{actions ? ( <GlobalStabilimentoSelector />
<div className="flex items-center"> </div>
{actions}
</div> <div className="flex items-center gap-4">
) : null} {userLabel ? (
</div> <span className="text-xs text-zinc-500 hidden lg:inline">{userLabel}</span>
</div> ) : null}
</nav> {actions ? <div className="flex items-center">{actions}</div> : null}
); </div>
</div>
</div>
</nav>
);
} }

View file

@ -57,6 +57,7 @@ interface TreeNodeProps {
onPaste: (targetId: number) => void; onPaste: (targetId: number) => void;
onShowArticles?: (nodeId: number) => void; onShowArticles?: (nodeId: number) => void;
onDropArticle?: (macchinaId: number, article: unknown) => void; onDropArticle?: (macchinaId: number, article: unknown) => void;
readOnly?: boolean;
} }
export default function TreeNode({ export default function TreeNode({
@ -74,6 +75,7 @@ export default function TreeNode({
onPaste, onPaste,
onShowArticles, onShowArticles,
onDropArticle, onDropArticle,
readOnly = false,
}: TreeNodeProps) { }: TreeNodeProps) {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
@ -100,10 +102,11 @@ export default function TreeNode({
const totalQuantity = getTotalQuantity(node); const totalQuantity = getTotalQuantity(node);
const handleDrop = (e: DragEvent<HTMLDivElement>) => { const handleDrop = (e: DragEvent<HTMLDivElement>) => {
if (readOnly) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setIsDragOver(false); setIsDragOver(false);
try { try {
const data = e.dataTransfer.getData("application/json"); const data = e.dataTransfer.getData("application/json");
if (data && onDropArticle) { if (data && onDropArticle) {
@ -136,6 +139,7 @@ export default function TreeNode({
}; };
const handleDragEnter = (e: DragEvent<HTMLDivElement>) => { const handleDragEnter = (e: DragEvent<HTMLDivElement>) => {
if (readOnly) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setIsDragOver(true); setIsDragOver(true);
@ -171,10 +175,10 @@ export default function TreeNode({
${getNodeTypeBg()} ${getNodeTypeBg()}
${isDragOver ? "ring-2 ring-blue-500 bg-blue-900/50 scale-[1.02]" : ""} ${isDragOver ? "ring-2 ring-blue-500 bg-blue-900/50 scale-[1.02]" : ""}
hover:bg-zinc-800/70`} hover:bg-zinc-800/70`}
onDragEnter={handleDragEnter} onDragEnter={readOnly ? undefined : handleDragEnter}
onDragOver={handleDragOver} onDragOver={readOnly ? undefined : handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={readOnly ? undefined : handleDragLeave}
onDrop={handleDrop} onDrop={readOnly ? undefined : handleDrop}
> >
{children.length > 0 && ( {children.length > 0 && (
<button <button
@ -214,6 +218,7 @@ export default function TreeNode({
</button> </button>
)} )}
{!readOnly && (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button <button
onClick={() => onAdd(node.id)} onClick={() => onAdd(node.id)}
@ -290,6 +295,7 @@ export default function TreeNode({
</svg> </svg>
</button> </button>
</div> </div>
)}
</div> </div>
{isExpanded && children.length > 0 && ( {isExpanded && children.length > 0 && (
@ -311,6 +317,7 @@ export default function TreeNode({
onPaste={onPaste} onPaste={onPaste}
onShowArticles={onShowArticles} onShowArticles={onShowArticles}
onDropArticle={onDropArticle} onDropArticle={onDropArticle}
readOnly={readOnly}
/> />
))} ))}
</div> </div>

View file

@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-md rounded-xl bg-zinc-900 border border-zinc-700 p-6 shadow-xl">
<h3 className="text-lg font-semibold text-white mb-4">{title}</h3>
<form onSubmit={onSubmit} className="space-y-4">
{children}
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg border border-zinc-700 text-zinc-300 hover:bg-zinc-800"
>
Annulla
</button>
<button
type="submit"
className="px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-500"
>
{submitLabel}
</button>
</div>
</form>
</div>
</div>
);
}
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<UserPublic[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [modal, setModal] = useState<"create" | "edit" | null>(null);
const [editing, setEditing] = useState<UserPublic | null>(null);
const [formUtente, setFormUtente] = useState("");
const [formMail, setFormMail] = useState("");
const [formRuolo, setFormRuolo] = useState<UserRole>("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<string, unknown> = {
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 (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">Gestione utenti</h1>
<p className="text-sm text-zinc-500">
Amministratori, tecnici e operatori con permessi differenziati
</p>
</div>
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="p-4 rounded-xl bg-zinc-900 border border-zinc-800">
<p className="text-sm text-zinc-500">Utenti totali</p>
<p className="text-2xl font-bold text-white">{utenti.length}</p>
</div>
<div className="p-4 rounded-xl bg-zinc-900 border border-zinc-800">
<p className="text-sm text-zinc-500">Attivi</p>
<p className="text-2xl font-bold text-green-400">{attivi}</p>
</div>
</div>
{error && (
<div className="mb-4 p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm flex justify-between items-center">
<span>{error}</span>
<button type="button" onClick={() => setError(null)} className="text-red-300 hover:text-white">
</button>
</div>
)}
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center justify-between mb-6">
<input
type="text"
value={search}
onChange={(e) => 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"
/>
<button
type="button"
onClick={openCreate}
className="px-4 py-2 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-500"
>
Nuovo utente
</button>
</div>
{loading ? (
<div className="flex items-center justify-center py-20 text-zinc-500">
<svg className="w-6 h-6 animate-spin mr-3" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Caricamento...
</div>
) : filtered.length === 0 ? (
<div className="p-12 rounded-xl bg-zinc-900 border border-zinc-800 text-center">
<h3 className="text-lg font-medium text-white mb-2">Nessun utente</h3>
<p className="text-zinc-500 mb-4">
{search ? "Nessun risultato per la ricerca" : "Aggiungi il primo utente"}
</p>
{!search && (
<button
type="button"
onClick={openCreate}
className="px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-500"
>
Crea utente
</button>
)}
</div>
) : (
<div className="rounded-xl bg-zinc-900 border border-zinc-800 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 text-zinc-500 text-left">
<th className="px-4 py-3 font-medium">Utente</th>
<th className="px-4 py-3 font-medium">Email</th>
<th className="px-4 py-3 font-medium">Ruolo</th>
<th className="px-4 py-3 font-medium">Stato</th>
<th className="px-4 py-3 font-medium">Ultimo accesso</th>
<th className="px-4 py-3 font-medium text-right">Azioni</th>
</tr>
</thead>
<tbody>
{filtered.map((user) => (
<tr key={user.id} className="border-b border-zinc-800/80 hover:bg-zinc-800/30">
<td className="px-4 py-3 text-white font-medium">{user.utente}</td>
<td className="px-4 py-3 text-zinc-400">{user.mail}</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded text-xs border ${roleBadgeClass(user.ruolo)}`}
>
{ROLE_LABELS[user.ruolo]}
</span>
</td>
<td className="px-4 py-3">
{user.attivo === 1 ? (
<span className="text-green-400">Attivo</span>
) : (
<span className="text-zinc-500">Disattivo</span>
)}
</td>
<td className="px-4 py-3 text-zinc-500 text-xs">
{formatLastLogin(user.last_login)}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-1">
<button
type="button"
onClick={() => openEdit(user)}
className="p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-zinc-800"
title="Modifica"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
type="button"
onClick={() => handleDelete(user)}
className="p-2 rounded-lg text-red-400 hover:text-red-300 hover:bg-red-500/10"
title="Elimina"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{modal && (
<FormModal
title={modal === "edit" ? "Modifica utente" : "Nuovo utente"}
onClose={() => setModal(null)}
onSubmit={handleSubmit}
submitLabel={modal === "edit" ? "Salva" : "Crea"}
>
<div>
<label className="block text-xs text-zinc-400 mb-1">Nome utente</label>
<input
type="text"
value={formUtente}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs text-zinc-400 mb-1">Email</label>
<input
type="email"
value={formMail}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs text-zinc-400 mb-1">Ruolo</label>
<select
value={formRuolo}
onChange={(e) => setFormRuolo(e.target.value as UserRole)}
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"
>
{USER_ROLES.map((r) => (
<option key={r} value={r}>
{ROLE_LABELS[r]}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-zinc-400 mb-1">Stato</label>
<select
value={formAttivo}
onChange={(e) => setFormAttivo(parseInt(e.target.value, 10) as 0 | 1)}
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"
>
<option value={1}>Attivo</option>
<option value={0}>Disattivo</option>
</select>
</div>
<div>
<label className="block text-xs text-zinc-400 mb-1">
Password {modal === "edit" && "(lascia vuoto per non cambiare)"}
</label>
<input
type="password"
value={formPassword}
onChange={(e) => 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"
/>
</div>
</FormModal>
)}
</div>
);
}

View file

@ -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<PermissionsContextValue | null>(null);
export function PermissionsProvider({
ruolo,
permissions,
children,
}: {
ruolo: UserRole;
permissions: AppPermissions;
children: ReactNode;
}) {
return (
<PermissionsContext.Provider value={{ ruolo, permissions }}>
{children}
</PermissionsContext.Provider>
);
}
export function usePermissions(): PermissionsContextValue {
const ctx = useContext(PermissionsContext);
if (!ctx) {
throw new Error("usePermissions deve essere usato dentro PermissionsProvider");
}
return ctx;
}

View file

@ -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<AuthSession | NextResponse> {
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<AuthSession | NextResponse> {
const result = await requireSession();
if (result instanceof NextResponse) return result;
if (!check(result.permissions)) {
return forbid(message);
}
return result;
}

72
src/lib/permissions.ts Normal file
View file

@ -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<string, UserRole> = {
admin: "amministratore",
sviluppo: "amministratore",
manager: "tecnico",
user: "operatore",
amministratore: "amministratore",
tecnico: "tecnico",
operatore: "operatore",
};
export const ROLE_LABELS: Record<UserRole, string> = {
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,
};
}
}

187
src/lib/users/index.ts Normal file
View file

@ -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<UserInput>, 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<UserPublic[]> {
const [rows] = await db.execute<UserRow[]>(
`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<UserPublic | null> {
const [rows] = await db.execute<UserRow[]>(
`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<void> {
const params: Record<string, string | number> = { 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<RowDataPacket[]>(query, params);
if (rows[0]) {
throw new Error("Nome utente o email già in uso.");
}
}
export async function createUser(input: Partial<UserInput>): Promise<UserPublic> {
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<ResultSetHeader>(
`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<UserInput>,
): Promise<UserPublic> {
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<void> {
const existing = await getUserById(id);
if (!existing) {
throw new Error("Utente non trovato.");
}
await db.execute("DELETE FROM utenti WHERE id = :id", { id });
}

View file

@ -1,4 +1,4 @@
export type UserRole = "admin" | "manager" | "user" | "sviluppo"; export type UserRole = "amministratore" | "tecnico" | "operatore";
export interface DbUser { export interface DbUser {
id: number; id: number;
@ -9,3 +9,21 @@ export interface DbUser {
attivo: 0 | 1; attivo: 0 | 1;
last_login?: Date | string | null; 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;
}