From 948cdb87658f0e0f3319fb121db1bd9ace9f1d29 Mon Sep 17 00:00:00 2001 From: AV77web Date: Mon, 13 Jul 2026 23:42:26 +0200 Subject: [PATCH] inserimento Login e modifica App.tsx --- package-lock.json | 60 +++++- package.json | 3 +- src/App.css | 313 ++++++++++++++---------------- src/App.tsx | 141 +++----------- src/api/client.ts | 115 +++++++++++ src/components/GuestRoute.tsx | 11 ++ src/components/Layout.tsx | 34 ++++ src/components/ProtectedRoute.tsx | 27 +++ src/context/AuthContext.tsx | 53 +++++ src/index.css | 112 ++--------- src/pages/Dashboard.tsx | 31 +++ src/pages/Login.tsx | 46 +++++ src/types/index.ts | 11 ++ src/validation/constraints.ts | 39 ++++ src/validation/primitives.ts | 200 +++++++++++++++++++ src/validation/utente.ts | 220 +++++++++++++++++++++ 16 files changed, 1026 insertions(+), 390 deletions(-) create mode 100644 src/api/client.ts create mode 100644 src/components/GuestRoute.tsx create mode 100644 src/components/Layout.tsx create mode 100644 src/components/ProtectedRoute.tsx create mode 100644 src/context/AuthContext.tsx create mode 100644 src/pages/Dashboard.tsx create mode 100644 src/pages/Login.tsx create mode 100644 src/types/index.ts create mode 100644 src/validation/constraints.ts create mode 100644 src/validation/primitives.ts create mode 100644 src/validation/utente.ts diff --git a/package-lock.json b/package-lock.json index 864dace..8b8b67d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "dependencies": { "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -1306,6 +1307,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2370,6 +2384,44 @@ "react": "^19.2.7" } }, + "node_modules/react-router": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -2420,6 +2472,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index 6979c1a..31ee609 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/src/App.css b/src/App.css index f90339d..ebf97d1 100644 --- a/src/App.css +++ b/src/App.css @@ -1,184 +1,161 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#center { +.layout { + min-height: 100vh; display: flex; flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } } -#next-steps { +.header { + background: #1e3a5f; + color: #fff; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.header-inner { + max-width: 1100px; + margin: 0 auto; + padding: 0.75rem 1.5rem; display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } + align-items: center; + gap: 1.5rem; + flex-wrap: wrap; } -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } +.logo { + color: #fff; + font-weight: 700; + font-size: 1.1rem; + text-decoration: none; } -#next-steps ul { - list-style: none; - padding: 0; +.header nav { display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } + gap: 1rem; + flex: 1; } -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } +.header nav a { + color: #cbd5e1; + text-decoration: none; + font-size: 0.95rem; } -.ticks { - position: relative; +.header nav a:hover { + color: #fff; +} + +.user-bar { + display: flex; + align-items: center; + gap: 1rem; + font-size: 0.9rem; +} + +.user-bar button { + background: transparent; + border: 1px solid #94a3b8; + color: #fff; + padding: 0.35rem 0.75rem; + border-radius: 6px; + cursor: pointer; +} + +.user-bar button:hover { + background: rgba(255, 255, 255, 0.1); +} + +.main { + flex: 1; + max-width: 1100px; width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } + margin: 0 auto; + padding: 2rem 1.5rem; } + +.page h1 { + margin: 0 0 0.5rem; + color: #1e293b; +} + +.auth-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + background: linear-gradient(135deg, #f0f4f8 0%, #e2e8f0 100%); +} + +.auth-card { + background: #fff; + padding: 2rem; + border-radius: 12px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08); + width: 100%; + max-width: 420px; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.auth-card h1 { + margin: 0; + color: #1e3a5f; +} + +.auth-link { + text-align: center; + font-size: 0.9rem; + color: #64748b; +} + +label { + display: flex; + flex-direction: column; + gap: 0.35rem; + font-size: 0.9rem; + font-weight: 500; + color: #334155; +} + +input { + padding: 0.6rem 0.75rem; + border: 1px solid #cbd5e1; + border-radius: 6px; + font-size: 1rem; + font-family: inherit; +} + +input:focus { + outline: 2px solid #3b82f6; + outline-offset: 1px; +} + +.btn { + display: inline-block; + padding: 0.6rem 1.2rem; + border-radius: 6px; + font-size: 0.95rem; + font-weight: 500; + text-decoration: none; + border: none; + cursor: pointer; + text-align: center; +} + +.btn-primary { + background: #2563eb; + color: #fff; +} + +.btn-primary:hover:not(:disabled) { + background: #1d4ed8; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.error { color: #dc2626; margin: 0; } +.muted { color: #64748b; } +.loading { color: #64748b; font-style: italic; } diff --git a/src/App.tsx b/src/App.tsx index a66b5ef..4aa1e07 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,122 +1,25 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from './assets/vite.svg' -import heroImg from './assets/hero.png' -import './App.css' - -function App() { - const [count, setCount] = useState(0) +import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; +import GuestRoute from "./components/GuestRoute"; +import Layout from "./components/Layout"; +import ProtectedRoute from "./components/ProtectedRoute"; +import { AuthProvider } from "./context/AuthContext"; +import Dashboard from "./pages/Dashboard"; +import Login from "./pages/Login"; +import "./App.css"; +export default function App() { return ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- - ) + + + + } /> + }> + } /> + + } /> + } /> + + + + ); } - -export default App diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..8be4a99 --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,115 @@ +import type { + Ruolo, + Utente, +} from "../types"; + +const API_URL = import.meta.env.VITE_API_URL ?? ""; + +async function request(path: string, options: RequestInit = {}): Promise { + const response = await fetch(`${API_URL}${path}`, { + credentials: "include", + headers: { + "Content-Type": "application/json", + ...(options.headers ?? {}), + }, + ...options, + }); + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + throw new Error(data.error ?? `Errore ${response.status}`); + } + + return data as T; +} + +export async function fetchHealth(): Promise<{ status: string }> { + return request("/api/health"); +} + +export async function getCsrfToken(): Promise { + const data = await request<{ csrfToken: string }>("/api/auth/csrf"); + 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`, { + method: "POST", + credentials: "include", + redirect: "manual", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-Auth-Return-Redirect": "1", + }, + body, + }); + + 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=")) { + throw new Error("Credenziali non valide"); + } +} + +export async function logout(): Promise { + const csrfToken = await getCsrfToken(); + const body = new URLSearchParams({ csrfToken }); + await fetch(`${API_URL}/api/auth/signout`, { + method: "POST", + credentials: "include", + redirect: "manual", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-Auth-Return-Redirect": "1", + }, + body, + }); +} + +interface MeResponse { + id: number; + email: string; + name: string; + ruolo: Ruolo; +} + +export async function getMe(): Promise { + const data = await request("/api/me"); + const nameParts = data.name.trim().split(/\s+/); + const nome = nameParts[0] ?? ""; + const cognome = nameParts.slice(1).join(" "); + + return { + id: data.id, + nome, + cognome, + email: data.email, + ruolo: data.ruolo, + }; +} + +export async function register(data: { + nome: string; + cognome: string; + email: string; + password: string; + confermaPassword: string; + ruolo: Ruolo; +}): Promise { + await request("/api/utenti/register", { + method: "POST", + body: JSON.stringify(data), + }); +} diff --git a/src/components/GuestRoute.tsx b/src/components/GuestRoute.tsx new file mode 100644 index 0000000..87de3db --- /dev/null +++ b/src/components/GuestRoute.tsx @@ -0,0 +1,11 @@ +import { Navigate } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; + +export default function GuestRoute({ children }: { children: React.ReactNode }) { + const { user, loading } = useAuth(); + + if (loading) return

Caricamento...

; + if (user) return ; + + return <>{children}; +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx new file mode 100644 index 0000000..63725c2 --- /dev/null +++ b/src/components/Layout.tsx @@ -0,0 +1,34 @@ +import { Link, Outlet, useNavigate } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; + +export default function Layout() { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + + const handleLogout = async () => { + await logout(); + navigate("/login"); + }; + + return ( +
+
+
+ + Esame ITS 24-26 + + +
+ {user?.nome} {user?.cognome} + +
+
+
+
+ +
+
+ ); +} diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..a1971be --- /dev/null +++ b/src/components/ProtectedRoute.tsx @@ -0,0 +1,27 @@ +import { Navigate } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; +import type { Ruolo } from "../types"; + +export default function ProtectedRoute({ + children, + roles, +}: { + children: React.ReactNode; + roles?: Ruolo[]; +}) { + const { user, loading } = useAuth(); + + if (loading) return

Caricamento...

; + if (!user) return ; + if (roles && !roles.includes(user.ruolo)) { + return ( + + ); + } + + return <>{children}; +} diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx new file mode 100644 index 0000000..e6cf6fc --- /dev/null +++ b/src/context/AuthContext.tsx @@ -0,0 +1,53 @@ +import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"; +import { getMe, login as apiLogin, logout as apiLogout } from "../api/client"; +import type { Utente } from "../types"; + +interface AuthContextValue { + user: Utente | null; + loading: boolean; + login: (email: string, password: string) => Promise; + logout: () => Promise; + refresh: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + try { + const me = await getMe(); + setUser(me); + } catch { + setUser(null); + } + }, []); + + useEffect(() => { + refresh().finally(() => setLoading(false)); + }, [refresh]); + + const login = async (email: string, password: string) => { + await apiLogin(email, password); + await refresh(); + }; + + const logout = async () => { + await apiLogout(); + setUser(null); + }; + + return ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth deve essere usato dentro AuthProvider"); + return ctx; +} diff --git a/src/index.css b/src/index.css index 5fb3313..ae829f4 100644 --- a/src/index.css +++ b/src/index.css @@ -1,111 +1,21 @@ -:root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } -} - -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } -} - -#root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; +*, +*::before, +*::after { box-sizing: border-box; } body { margin: 0; + font-family: "Segoe UI", system-ui, -apple-system, sans-serif; + background: #f1f5f9; + color: #1e293b; + line-height: 1.5; } -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); +#root { + min-height: 100vh; } -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} -p { - margin: 0; -} - -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); -} - -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); +a { + color: #2563eb; } diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx new file mode 100644 index 0000000..8e5eaa7 --- /dev/null +++ b/src/pages/Dashboard.tsx @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; + +export default function Dashboard() { + const { user } = useAuth(); + const location = useLocation(); + const navigate = useNavigate(); + const isAdmin = user?.ruolo === "Amministratore"; + const [deniedMessage, setDeniedMessage] = useState(null); + + useEffect(() => { + const denied = (location.state as { denied?: string } | null)?.denied; + if (denied) { + setDeniedMessage(denied); + navigate(location.pathname, { replace: true, state: {} }); + } + }, [location, navigate]); + + return ( +
+

Dashboard

+ {deniedMessage &&

{deniedMessage}

} +

+ Benvenuto, {user?.nome} {user?.cognome} +

+

Ruolo: {isAdmin ? "Amministratore" : "Operatore"}

+

Email: {user?.email}

+
+ ); +} diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx new file mode 100644 index 0000000..a7827bf --- /dev/null +++ b/src/pages/Login.tsx @@ -0,0 +1,46 @@ +import { useState, type FormEvent } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; + +export default function Login() { + const { login } = useAuth(); + const navigate = useNavigate(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + try { + await login(email, password); + navigate("/dashboard"); + } catch (err) { + setError(err instanceof Error ? err.message : "Errore di accesso"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Accedi

+ {error &&

{error}

} + + + +
+
+ ); +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..f8514c1 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,11 @@ +export type Ruolo = "Operatore" | "Amministratore"; + + +export interface Utente { + id: number; + nome: string; + cognome: string; + email: string; + ruolo: Ruolo; +} + diff --git a/src/validation/constraints.ts b/src/validation/constraints.ts new file mode 100644 index 0000000..0d8ac6c --- /dev/null +++ b/src/validation/constraints.ts @@ -0,0 +1,39 @@ +//=================================================== +// File: constraints.ts +// Vincoli derivati da initdb/01_schema.sql. +// author: "villari.andrea@libero.it" +// version: "1.0.0 2026-06-24" +//=================================================== + +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: ["Operatore", "Amministratore"], + required: true, + }, +} as const; + +export const CLIENTE_CONSTRAINTS = { + nome: { maxLength: 100, required: true }, + cognome: { maxLength: 100, required: true }, + via: { maxLength: 100, required: true }, + comune: { maxLength: 100, required: true }, + provincia: { maxLength: 2, required: true }, + telefono: { maxLength: 30, required: true }, + email: { maxLength: 255, required: true }, + note: { required: false }, +} as const; + +export const CONSEGNA_CONSTRAINTS = { + dataRitiro: { required: false }, + dataConsegna: { required: false }, + stato: { + allowedValues: ["da ritirare", "in consegna", "in giacenza", "in deposito"], + required: true, + }, + chiaveConsegna: { maxLength: 8, required: true }, + clienteId: { required: true }, +} as const; diff --git a/src/validation/primitives.ts b/src/validation/primitives.ts new file mode 100644 index 0000000..9d6edd1 --- /dev/null +++ b/src/validation/primitives.ts @@ -0,0 +1,200 @@ +//========================================== +// File: primitives.ts +// Funzioni di validazione dei dati prmitivi +// author: "villari.andrea@libero.it" +// version: "1.0.0 2026-06-18" +//========================================== + +export type FieldErrors = Record; + +export type ValidationResult = + | { success: true; data: T } + | { success: false; errors: FieldErrors }; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export function normalizeString(value: unknown): string { + if (value == null) return ""; + return value.toString().trim(); +} + +export function normalizeEmail(value: unknown): string { + return normalizeString(value).toLowerCase(); +} + +export function normalizeBoolean(value: unknown, defaultValue = false): boolean { + if (value === true || value === "true") return true; + if (value === false || value === "false") return false; + return defaultValue; +} + +export function normalizeDecimal(value: unknown): number { + if (typeof value === "number") return value; + const normalized = value?.toString().trim().replace(",", ".") ?? ""; + if (!normalized) return Number.NaN; + return Number(normalized); +} + +export function normalizePositiveInt(value: unknown): number { + if (typeof value === "number") return value; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : Number.NaN; +} + +export function normalizeTime(value: unknown): string { + const str = normalizeString(value); + if (!str) return ""; + if (/^\d{2}:\d{2}$/.test(str)) return `${str}:00`; + return str; +} + +export function validateRequired( + errors: FieldErrors, + field: string, + value: string, + message: string +): void { + if (!value) errors[field] = message; +} + +export function validateMaxLength( + errors: FieldErrors, + field: string, + value: string, + maxLength: number, + label: string +): void { + if (value.length > maxLength) { + errors[field] = `${label} deve contenere al massimo ${maxLength} caratteri`; + } +} + +export function validateMinLength( + errors: FieldErrors, + field: string, + value: string, + minLength: number, + label: string +): void { + if (value && value.length < minLength) { + errors[field] = `${label} deve contenere almeno ${minLength} caratteri`; + } +} + +export function validateEmailFormat( + errors: FieldErrors, + field: string, + value: string +): void { + if (value && !EMAIL_REGEX.test(value)) { + errors[field] = "Formato email non valido"; + } +} + +export function validateEnum( + errors: FieldErrors, + field: string, + value: string, + allowedValues: readonly string[], + label: string +): void { + if (value && !allowedValues.includes(value)) { + errors[field] = `${label} non valido`; + } +} + +export function validateBoolean( + errors: FieldErrors, + field: string, + value: unknown, + label: string +): void { + if (value === undefined || value === null || value === "") return; + if ( + value !== true && + value !== false && + value !== "true" && + value !== "false" + ) { + errors[field] = `${label} non valido`; + } +} + +const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; +const TIME_REGEX = /^\d{2}:\d{2}:\d{2}$/; + +export function validateDate( + errors: FieldErrors, + field: string, + value: string, + label: string +): void { + if (!value) return; + if (!DATE_REGEX.test(value)) { + errors[field] = `${label} non valida`; + return; + } + const parsed = new Date(`${value}T00:00:00`); + if (Number.isNaN(parsed.getTime())) { + errors[field] = `${label} non valida`; + } +} + +export function validateTimeFormat( + errors: FieldErrors, + field: string, + value: string, + label: string +): void { + if (!value) return; + if (!TIME_REGEX.test(value)) { + errors[field] = `${label} non valida`; + } +} + +export function validatePositiveInt( + errors: FieldErrors, + field: string, + value: number, + label: string, + required = true +): void { + if (Number.isNaN(value) || value <= 0) { + errors[field] = required ? `${label} obbligatorio` : `${label} non valido`; + } +} + +export function validateTimeRange( + errors: FieldErrors, + _startField: string, + endField: string, + start: string, + end: string +): void { + if (!start || !end) return; + if (start >= end) { + errors[endField] = + "L'ora di fine deve essere successiva all'ora di inizio"; + } +} + +export function validateDecimalPositive( + errors: FieldErrors, + field: string, + value: unknown, + label: string, + min = 0.01 +): void { + if (value === undefined || value === null || value === "") return; + const num = typeof value === "number" ? value : Number(value); + if (Number.isNaN(num) || num < min) { + errors[field] = `${label} deve essere almeno ${min}`; + } +} + +export function buildResult(data: T, errors: FieldErrors): ValidationResult { + if (Object.keys(errors).length > 0) { + return { success: false as const, errors }; + } + return { success: true as const, data }; +} diff --git a/src/validation/utente.ts b/src/validation/utente.ts new file mode 100644 index 0000000..007b3e1 --- /dev/null +++ b/src/validation/utente.ts @@ -0,0 +1,220 @@ +import { UTENTE_CONSTRAINTS } from "./constraints"; +import { + buildResult, + normalizeEmail, + normalizeString, + validateEmailFormat, + validateEnum, + validateMaxLength, + validateMinLength, + validateRequired, + type FieldErrors, + type ValidationResult, +} from "./primitives"; + +export type Ruolo = (typeof UTENTE_CONSTRAINTS.ruolo.allowedValues)[number]; + +export interface LoginInput { + email: string; + password: string; +} + +export interface UtenteCreateInput { + nome: string; + cognome: string; + email: string; + password: string; + ruolo: Ruolo; +} + +export type UtenteUpdateInput = Partial; + +export function normalizeLoginInput(raw: Record): LoginInput { + return { + email: normalizeEmail(raw.email), + password: raw.password?.toString() ?? "", + }; +} + +export function validateLoginInput( + raw: Record +): ValidationResult { + const data = normalizeLoginInput(raw); + 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); +} + +function validateUtenteProfileFields( + data: UtenteCreateInput, + 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); + + validateRequired(errors, "password", data.password, "Password obbligatoria"); + validateMinLength( + errors, + "password", + data.password, + UTENTE_CONSTRAINTS.password.minLength, + "Password" + ); + validateMaxLength( + errors, + "password", + data.password, + UTENTE_CONSTRAINTS.password.maxLength, + "Password" + ); + + validateRequired(errors, "ruolo", data.ruolo, "Ruolo obbligatorio"); + validateEnum( + errors, + "ruolo", + data.ruolo, + UTENTE_CONSTRAINTS.ruolo.allowedValues, + "Ruolo" + ); +} + +export function validateUtenteCreate( + raw: Record +): ValidationResult { + const data: UtenteCreateInput = { + 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 = {}; + + validateUtenteProfileFields(data, errors); + + return buildResult(data, errors); +} + +export function validateUtenteUpdate( + raw: Record +): ValidationResult { + const errors: FieldErrors = {}; + const data: UtenteUpdateInput = {}; + + if ("nome" in raw) { + data.nome = normalizeString(raw.nome); + validateRequired(errors, "nome", data.nome, "Nome obbligatorio"); + validateMaxLength( + errors, + "nome", + data.nome, + UTENTE_CONSTRAINTS.nome.maxLength, + "Nome" + ); + } + + if ("cognome" in raw) { + data.cognome = normalizeString(raw.cognome); + validateRequired(errors, "cognome", data.cognome, "Cognome obbligatorio"); + validateMaxLength( + errors, + "cognome", + data.cognome, + UTENTE_CONSTRAINTS.cognome.maxLength, + "Cognome" + ); + } + + if ("email" in raw) { + data.email = normalizeEmail(raw.email); + validateRequired(errors, "email", data.email, "Email obbligatoria"); + validateMaxLength( + errors, + "email", + data.email, + UTENTE_CONSTRAINTS.email.maxLength, + "Email" + ); + validateEmailFormat(errors, "email", data.email); + } + + if ("password" in raw) { + const password = raw.password?.toString() ?? ""; + if (password) { + data.password = password; + validateMinLength( + errors, + "password", + password, + UTENTE_CONSTRAINTS.password.minLength, + "Password" + ); + validateMaxLength( + errors, + "password", + password, + UTENTE_CONSTRAINTS.password.maxLength, + "Password" + ); + } + } + + if ("ruolo" in raw) { + data.ruolo = normalizeString(raw.ruolo) as Ruolo; + validateRequired(errors, "ruolo", data.ruolo, "Ruolo obbligatorio"); + validateEnum( + errors, + "ruolo", + data.ruolo, + UTENTE_CONSTRAINTS.ruolo.allowedValues, + "Ruolo" + ); + } + + if (Object.keys(data).length === 0) { + errors._form = "Nessun campo da aggiornare"; + } + + return buildResult(data, errors); +}