diff --git a/src/App.tsx b/src/App.tsx index d657438..4042f17 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,7 +18,7 @@ import MieiCorsi from "@/pages/MieiCorsi"; import MioCorsoDettaglio from "@/pages/MioCorsoDettaglio"; import Corsi from "@/pages/Corsi"; import Assegnazioni from "@/pages/Assegnazioni"; -import Dipendenti from "@/pages/Dipendenti"; +import Utenti from "@/pages/Utenti"; import Statistiche from "@/pages/Statistiche"; export default function App() { @@ -63,10 +63,10 @@ export default function App() { } /> - + } /> diff --git a/src/api/client.ts b/src/api/client.ts index cd5c1c2..744d9b5 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -53,44 +53,11 @@ export async function getCsrfToken(): Promise { return data.csrfToken; } -export async function login(email: string, password: string): Promise { - const csrfToken = await getCsrfToken(); - const body = new URLSearchParams({ - csrfToken, - email, - password, - redirect: "false", - }); - - const response = await fetch(`${API_URL}/api/auth/callback/credentials`, { +export async function login(email: string, password: string): Promise { + return request("/api/utenti/login", { method: "POST", - credentials: "include", - redirect: "manual", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "X-Auth-Return-Redirect": "1", - }, - body, + body: JSON.stringify({ email, password }), }); - - if (response.type === "opaqueredirect" || response.status === 302) { - throw new Error("Login non riuscito: frontend non aggiornato o configurazione Auth errata"); - } - - const data = await response.json().catch(() => ({})) as { url?: string }; - if (!response.ok || data.url?.includes("error=")) { - const errorType = data.url?.match(/error=([^&]+)/)?.[1]; - if (errorType === "CredentialsSignin") { - throw new Error("Credenziali non valide"); - } - if (errorType === "MissingCSRF") { - throw new Error("Sessione scaduta: ricarica la pagina e riprova"); - } - if (errorType === "Configuration") { - throw new Error("Errore di configurazione del server (database o auth)"); - } - throw new Error("Accesso non riuscito"); - } } export async function logout(): Promise { @@ -265,6 +232,37 @@ export async function getUtenti(filters?: { ruolo?: Ruolo }): Promise return request(`/api/utenti${buildQuery({ ruolo: filters?.ruolo })}`); } +export interface UtenteInput { + nome: string; + cognome: string; + email: string; + password: string; + ruolo: Ruolo; +} + +export async function createUtente(data: UtenteInput): Promise { + return request("/api/utenti/register", { + method: "POST", + body: JSON.stringify(data), + }); +} + +export async function updateUtente( + id: number, + data: { + nome: string; + cognome: string; + email: string; + ruolo: Ruolo; + password?: string; + } +): Promise { + return request(`/api/utenti/${id}`, { + method: "PUT", + body: JSON.stringify(data), + }); +} + export async function getStatisticheAcademy(filters?: { mese?: string; daMese?: string; diff --git a/src/config/navigation.ts b/src/config/navigation.ts index cf3fae5..6e39fc9 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -41,8 +41,8 @@ export const NAV_ITEMS: NavItem[] = [ roles: ["Referente Academy"], }, { - label: "Dipendenti", - to: "/dipendenti", + label: "Utenti", + to: "/utenti", icon: Users, roles: ["Referente Academy"], }, diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index d93257e..5b1d11f 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -302,9 +302,9 @@ function ReferenteDashboard() { to="/assegnazioni" /> - {Array.from({ length: 4 }).map((_, index) => ( - - ))} - - ); -} - -function matchesSearch(utente: Utente, query: string): boolean { - const normalized = query.trim().toLowerCase(); - if (!normalized) return true; - - return [utente.nome, utente.cognome, utente.email] - .join(" ") - .toLowerCase() - .includes(normalized); -} - -export default function Dipendenti() { - const [dipendenti, setDipendenti] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [search, setSearch] = useState(""); - - useEffect(() => { - setLoading(true); - setError(null); - - getUtenti({ ruolo: "Dipendente" }) - .then(setDipendenti) - .catch((err) => - setError(err instanceof Error ? err.message : "Errore nel caricamento") - ) - .finally(() => setLoading(false)); - }, []); - - const filtered = useMemo( - () => dipendenti.filter((d) => matchesSearch(d, search)), - [dipendenti, search] - ); - - return ( -
-
-

Dipendenti

-

- Elenco dei dipendenti dell'organizzazione iscritti all'Academy. -

-
- - {error && ( - - - {error} - - )} - - - - - - Ricerca - - - Filtra per nome, cognome o email. - - - -
- - setSearch(e.target.value)} - placeholder="Es. Maria, Bianchi, academy.it" - /> -
-
-
- - - - - - {loading ? "Caricamento..." : `${filtered.length} dipendenti`} - - - - {loading ? ( - - ) : filtered.length === 0 ? ( -

- {search - ? "Nessun dipendente corrisponde alla ricerca." - : "Nessun dipendente registrato."} -

- ) : ( - - - - Cognome - Nome - Email - Azioni - - - - {filtered.map((dipendente) => ( - - - {dipendente.cognome} - - {dipendente.nome} - {dipendente.email} - - - - - ))} - -
- )} -
-
-
- ); -} diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index f2c108e..83702aa 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -7,6 +7,7 @@ import { CircleAlert } from "lucide-react"; import { useState, type FormEvent } from "react"; import { useNavigate } from "react-router-dom"; +import { ApiClientError } from "@/api/client"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { @@ -19,6 +20,13 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useAuth } from "@/context/AuthContext"; +import { validateLoginForm } from "@/validation/utente"; +import type { FieldErrors } from "@/validation/primitives"; + +function FieldError({ message }: { message?: string }) { + if (!message) return null; + return

{message}

; +} export default function Login() { const { login } = useAuth(); @@ -26,16 +34,28 @@ export default function Login() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(null); + const [fieldErrors, setFieldErrors] = useState({}); const [loading, setLoading] = useState(false); const handleSubmit = async (e: FormEvent) => { e.preventDefault(); setError(null); + + const validation = validateLoginForm({ email, password }); + if (validation.success === false) { + setFieldErrors(validation.errors); + return; + } + + setFieldErrors({}); setLoading(true); try { - await login(email, password); + await login(validation.data.email, validation.data.password); navigate("/dashboard"); } catch (err) { + if (err instanceof ApiClientError && err.fields) { + setFieldErrors(err.fields); + } setError(err instanceof Error ? err.message : "Errore di accesso"); } finally { setLoading(false); @@ -68,10 +88,18 @@ export default function Login() { autoComplete="email" placeholder="nome@academy.it" value={email} - onChange={(e) => setEmail(e.target.value)} - required + onChange={(e) => { + setEmail(e.target.value); + setFieldErrors((prev) => { + const next = { ...prev }; + delete next.email; + return next; + }); + }} + aria-invalid={Boolean(fieldErrors.email)} disabled={loading} /> +
@@ -82,10 +110,18 @@ export default function Login() { autoComplete="current-password" placeholder="••••••••" value={password} - onChange={(e) => setPassword(e.target.value)} - required + onChange={(e) => { + setPassword(e.target.value); + setFieldErrors((prev) => { + const next = { ...prev }; + delete next.password; + return next; + }); + }} + aria-invalid={Boolean(fieldErrors.password)} disabled={loading} /> +
+ + + + + + ); +} + +export default function Utenti() { + const [utenti, setUtenti] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(""); + const [ruoloFilter, setRuoloFilter] = useState(ALL_VALUE); + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogMode, setDialogMode] = useState<"create" | "edit">("create"); + const [selectedUtente, setSelectedUtente] = useState(null); + + const loadUtenti = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await getUtenti( + ruoloFilter === ALL_VALUE + ? undefined + : { ruolo: ruoloFilter as Ruolo } + ); + setUtenti(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Errore nel caricamento"); + } finally { + setLoading(false); + } + }, [ruoloFilter]); + + useEffect(() => { + loadUtenti(); + }, [loadUtenti]); + + const filtered = useMemo( + () => utenti.filter((u) => matchesSearch(u, search)), + [utenti, search] + ); + + function openCreateDialog() { + setDialogMode("create"); + setSelectedUtente(null); + setDialogOpen(true); + } + + function openEditDialog(utente: Utente) { + setDialogMode("edit"); + setSelectedUtente(utente); + setDialogOpen(true); + } + + return ( +
+
+
+

Utenti

+

+ Gestisci i Dipendenti e i Referenti Academy dell'organizzazione. +

+
+ +
+ + {error && ( + + + {error} + + )} + + + + + + Filtri + + + Filtra per ruolo o cerca per nome, cognome ed email. + + + +
+ + setSearch(e.target.value)} + placeholder="Es. Maria, Bianchi, academy.it" + /> +
+
+ + +
+
+
+ + + + + + {loading ? "Caricamento..." : `${filtered.length} utenti`} + + + + {loading ? ( + + ) : filtered.length === 0 ? ( +

+ {search + ? "Nessun utente corrisponde ai filtri selezionati." + : "Nessun utente registrato."} +

+ ) : ( + + + + Cognome + Nome + Email + Ruolo + Azioni + + + + {filtered.map((utente) => ( + + + {utente.cognome} + + {utente.nome} + {utente.email} + + {utente.ruolo} + + +
+ + {utente.ruolo === "Dipendente" && ( + + )} +
+
+
+ ))} +
+
+ )} +
+
+ + +
+ ); +} diff --git a/src/validation/constraints.ts b/src/validation/constraints.ts index 015870e..3163515 100644 --- a/src/validation/constraints.ts +++ b/src/validation/constraints.ts @@ -5,6 +5,17 @@ // @version: "1.0.0 2026-07-14" //=================================================== +export const UTENTE_CONSTRAINTS = { + nome: { maxLength: 100, required: true }, + cognome: { maxLength: 100, required: true }, + email: { maxLength: 255, required: true }, + password: { minLength: 8, maxLength: 72, required: true }, + ruolo: { + allowedValues: ["Dipendente", "Referente Academy"] as const, + required: true, + }, +} as const; + export const CORSO_CONSTRAINTS = { titolo: { maxLength: 200, required: true }, descrizione: { maxLength: 65535, required: false }, diff --git a/src/validation/utente.ts b/src/validation/utente.ts new file mode 100644 index 0000000..84d0f9b --- /dev/null +++ b/src/validation/utente.ts @@ -0,0 +1,218 @@ +//======================================= +// File: utente.ts +// Validazione lato frontend dei dati utente. +// @author: "villari.andrea@libero.it" +// @version: "1.0.0 2026-07-14" +//======================================= + +import { UTENTE_CONSTRAINTS } from "./constraints"; +import { + buildResult, + normalizeEmail, + normalizeString, + validateEmailFormat, + validateEnum, + validateMaxLength, + validateMinLength, + validateRequired, + type FieldErrors, + type ValidationResult, +} from "./primitives"; +import type { Ruolo, Utente } from "@/types"; + +export interface UtenteFormInput { + nome: string; + cognome: string; + email: string; + password: string; + ruolo: Ruolo | ""; +} + +export interface UtenteCreatePayload { + nome: string; + cognome: string; + email: string; + password: string; + ruolo: Ruolo; +} + +export interface UtenteUpdatePayload { + nome: string; + cognome: string; + email: string; + ruolo: Ruolo; + password?: string; +} + +export interface LoginFormInput { + email: string; + password: string; +} + +function validateNomeCognomeEmail( + data: Pick, + errors: FieldErrors +): void { + validateRequired(errors, "nome", data.nome, "Nome obbligatorio"); + validateMaxLength( + errors, + "nome", + data.nome, + UTENTE_CONSTRAINTS.nome.maxLength, + "Nome" + ); + validateRequired(errors, "cognome", data.cognome, "Cognome obbligatorio"); + validateMaxLength( + errors, + "cognome", + data.cognome, + UTENTE_CONSTRAINTS.cognome.maxLength, + "Cognome" + ); + validateRequired(errors, "email", data.email, "Email obbligatoria"); + validateMaxLength( + errors, + "email", + data.email, + UTENTE_CONSTRAINTS.email.maxLength, + "Email" + ); + validateEmailFormat(errors, "email", data.email); +} + +function validatePasswordField( + errors: FieldErrors, + password: string, + options: { required: boolean } +): void { + if (options.required) { + validateRequired(errors, "password", password, "Password obbligatoria"); + } + if (!password) return; + validateMinLength( + errors, + "password", + password, + UTENTE_CONSTRAINTS.password.minLength, + "Password" + ); + validateMaxLength( + errors, + "password", + password, + UTENTE_CONSTRAINTS.password.maxLength, + "Password" + ); +} + +export function emptyUtenteForm(): UtenteFormInput { + return { + nome: "", + cognome: "", + email: "", + password: "", + ruolo: "Dipendente", + }; +} + +export function utenteToForm(utente: Utente): UtenteFormInput { + return { + nome: utente.nome, + cognome: utente.cognome, + email: utente.email, + password: "", + ruolo: utente.ruolo, + }; +} + +export function validateLoginForm( + raw: LoginFormInput +): ValidationResult { + const data: LoginFormInput = { + email: normalizeEmail(raw.email), + password: raw.password?.toString() ?? "", + }; + const errors: FieldErrors = {}; + + validateRequired(errors, "email", data.email, "Email obbligatoria"); + validateMaxLength( + errors, + "email", + data.email, + UTENTE_CONSTRAINTS.email.maxLength, + "Email" + ); + validateEmailFormat(errors, "email", data.email); + validateRequired(errors, "password", data.password, "Password obbligatoria"); + validateMaxLength( + errors, + "password", + data.password, + UTENTE_CONSTRAINTS.password.maxLength, + "Password" + ); + + return buildResult(data, errors); +} + +export function validateUtenteCreate( + raw: UtenteFormInput +): ValidationResult { + const data: UtenteCreatePayload = { + nome: normalizeString(raw.nome), + cognome: normalizeString(raw.cognome), + email: normalizeEmail(raw.email), + password: raw.password?.toString() ?? "", + ruolo: normalizeString(raw.ruolo) as Ruolo, + }; + const errors: FieldErrors = {}; + + validateNomeCognomeEmail(data, errors); + validatePasswordField(errors, data.password, { required: true }); + validateRequired(errors, "ruolo", data.ruolo, "Ruolo obbligatorio"); + validateEnum( + errors, + "ruolo", + data.ruolo, + UTENTE_CONSTRAINTS.ruolo.allowedValues, + "Ruolo" + ); + + return buildResult(data, errors); +} + +export function validateUtenteUpdate( + raw: UtenteFormInput +): ValidationResult { + const data: UtenteUpdatePayload = { + nome: normalizeString(raw.nome), + cognome: normalizeString(raw.cognome), + email: normalizeEmail(raw.email), + ruolo: normalizeString(raw.ruolo) as Ruolo, + }; + const errors: FieldErrors = {}; + + validateNomeCognomeEmail( + { + nome: data.nome ?? "", + cognome: data.cognome ?? "", + email: data.email ?? "", + }, + errors + ); + validateEnum( + errors, + "ruolo", + data.ruolo, + UTENTE_CONSTRAINTS.ruolo.allowedValues, + "Ruolo" + ); + + const password = raw.password?.toString() ?? ""; + if (password) { + data.password = password; + validatePasswordField(errors, password, { required: false }); + } + + return buildResult(data, errors); +}