inserita la gestione degli utenti
This commit is contained in:
parent
bdb3145bd6
commit
b7fd15ffcd
29 changed files with 1145 additions and 216 deletions
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
88
src/app/api/utenti/[id]/route.ts
Normal file
88
src/app/api/utenti/[id]/route.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/app/api/utenti/route.ts
Normal file
36
src/app/api/utenti/route.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/auth.ts
16
src/auth.ts
|
|
@ -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;
|
||||||
|
|
|
||||||
38
src/components/AppShell.tsx
Normal file
38
src/components/AppShell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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,6 +428,7 @@ export default function MacchineTree() {
|
||||||
{filteredCount} elementi in {selectedStabilimento.descrizione}
|
{filteredCount} elementi in {selectedStabilimento.descrizione}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
onClick={handleAddMachine}
|
onClick={handleAddMachine}
|
||||||
disabled={isCloning}
|
disabled={isCloning}
|
||||||
|
|
@ -434,6 +439,7 @@ export default function MacchineTree() {
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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,6 +493,8 @@ 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 "{selectedStabilimento.descrizione}"
|
Nessuna macchina in "{selectedStabilimento.descrizione}"
|
||||||
</p>
|
</p>
|
||||||
|
{!readOnly && (
|
||||||
|
<>
|
||||||
<p className="text-zinc-600 text-xs mb-4">
|
<p className="text-zinc-600 text-xs mb-4">
|
||||||
Clicca sul pulsante + per aggiungere la prima macchina
|
Clicca sul pulsante + per aggiungere la prima macchina
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -496,6 +504,8 @@ export default function MacchineTree() {
|
||||||
>
|
>
|
||||||
Aggiungi macchina
|
Aggiungi macchina
|
||||||
</button>
|
</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();
|
||||||
|
|
|
||||||
|
|
@ -2,34 +2,50 @@
|
||||||
// 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 { ReactNode } from "react";
|
||||||
import Link from "next/link";
|
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({
|
||||||
|
actions,
|
||||||
|
permissions,
|
||||||
|
userLabel,
|
||||||
|
}: {
|
||||||
|
actions?: ReactNode;
|
||||||
|
permissions: AppPermissions;
|
||||||
|
userLabel?: string;
|
||||||
|
}) {
|
||||||
|
const visibleLinks = navLinks.filter((link) => link.visible(permissions));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="border-b border-zinc-800 bg-black">
|
<nav className="border-b border-zinc-800 bg-black">
|
||||||
<div className="max-w-7xl mx-auto px-4">
|
<div className="max-w-7xl mx-auto px-4">
|
||||||
<div className="flex items-center justify-between h-32">
|
<div className="flex items-center justify-between h-32">
|
||||||
{/* Logo */}
|
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="flex items-center hover:opacity-80 transition-opacity"
|
className="flex items-center hover:opacity-80 transition-opacity"
|
||||||
|
|
@ -44,9 +60,8 @@ export default function Navbar({ actions }: { actions?: ReactNode }) {
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Navigation Links */}
|
|
||||||
<div className="flex items-center space-x-6">
|
<div className="flex items-center space-x-6">
|
||||||
{navLinks.map((link) => (
|
{visibleLinks.map((link) => (
|
||||||
<Link
|
<Link
|
||||||
key={link.href}
|
key={link.href}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
|
|
@ -57,17 +72,16 @@ export default function Navbar({ actions }: { actions?: ReactNode }) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stabilimento Selector */}
|
|
||||||
<div className="flex-1 flex justify-center px-4">
|
<div className="flex-1 flex justify-center px-4">
|
||||||
<GlobalStabilimentoSelector />
|
<GlobalStabilimentoSelector />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
<div className="flex items-center gap-4">
|
||||||
{actions ? (
|
{userLabel ? (
|
||||||
<div className="flex items-center">
|
<span className="text-xs text-zinc-500 hidden lg:inline">{userLabel}</span>
|
||||||
{actions}
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
{actions ? <div className="flex items-center">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -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,6 +102,7 @@ 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);
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
389
src/components/UtentiManager.tsx
Normal file
389
src/components/UtentiManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/contexts/PermissionsContext.tsx
Normal file
37
src/contexts/PermissionsContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
62
src/lib/auth/requireSession.ts
Normal file
62
src/lib/auth/requireSession.ts
Normal 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
72
src/lib/permissions.ts
Normal 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
187
src/lib/users/index.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue