From 841f999110ebbb33e0ad5fce14f78980494200f1 Mon Sep 17 00:00:00 2001 From: AV77web Date: Wed, 24 Jun 2026 16:40:28 +0200 Subject: [PATCH] installazione di reac-router-dom e creazione Dachboard, Login, AuthContext, GuestRoute e ProtectdRoute e api/clinet.ts --- package-lock.json | 60 ++++++- package.json | 3 +- src/App.css | 285 +++++++++++++----------------- src/App.tsx | 145 +++------------ src/api/client.ts | 215 ++++++++++++++++++++++ src/components/GuestRoute.tsx | 11 ++ src/components/ProtectedRoute.tsx | 26 +++ src/context/AuthContext.tsx | 53 ++++++ src/pages/Dashboard.tsx | 25 +++ src/pages/Login.tsx | 70 ++++++++ src/types/index.ts | 70 ++++++++ 11 files changed, 675 insertions(+), 288 deletions(-) create mode 100644 src/api/client.ts create mode 100644 src/components/GuestRoute.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 diff --git a/package-lock.json b/package-lock.json index e1187b6..adca22d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "dependencies": { "react": "^19.2.6", - "react-dom": "^19.2.6" + "react-dom": "^19.2.6", + "react-router-dom": "^7.18.0" }, "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.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", + "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", + "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.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz", + "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/rolldown": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.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 9bc2fe0..a4ce236 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "react": "^19.2.6", - "react-dom": "^19.2.6" + "react-dom": "^19.2.6", + "react-router-dom": "^7.18.0" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/src/App.css b/src/App.css index f90339d..feac62a 100644 --- a/src/App.css +++ b/src/App.css @@ -1,184 +1,135 @@ -.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; - } +.auth-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + background: linear-gradient(135deg, #f0f4f8 0%, #e2e8f0 100%); } -.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 { +.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: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } + gap: 1rem; } -#next-steps { +.auth-card h1 { + margin: 0; + color: #1e3a5f; + font-size: 1.5rem; +} + +.auth-subtitle { + margin: 0; + color: #64748b; + font-size: 0.95rem; +} + +.auth-card label { 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; - } + flex-direction: column; + gap: 0.35rem; + font-size: 0.9rem; + color: #334155; } -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } +.auth-card input { + padding: 0.65rem 0.75rem; + border: 1px solid #cbd5e1; + border-radius: 8px; + font-size: 1rem; } -#next-steps ul { - list-style: none; - padding: 0; +.auth-card input:focus { + outline: 2px solid #3b82f6; + outline-offset: 1px; + border-color: #3b82f6; +} + +.btn { + padding: 0.7rem 1rem; + border: none; + border-radius: 8px; + font-size: 1rem; + cursor: pointer; + font-weight: 600; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-primary { + background: #1e3a5f; + color: #fff; +} + +.btn-primary:hover:not(:disabled) { + background: #2d4a6f; +} + +.btn-secondary { + background: #e2e8f0; + color: #1e293b; +} + +.btn-secondary:hover { + background: #cbd5e1; +} + +.error { + margin: 0; + padding: 0.65rem 0.75rem; + background: #fef2f2; + color: #b91c1c; + border-radius: 8px; + font-size: 0.9rem; +} + +.loading { + text-align: center; + padding: 2rem; + color: #64748b; +} + +.dashboard-page { + min-height: 100vh; + background: #f8fafc; +} + +.dashboard-header { 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; - } - } + justify-content: space-between; + align-items: center; + padding: 1.5rem 2rem; + background: #1e3a5f; + color: #fff; } -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } +.dashboard-header h1 { + margin: 0 0 0.25rem; + font-size: 1.5rem; } -.ticks { - position: relative; - 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); - } +.dashboard-header p { + margin: 0; + opacity: 0.9; +} + +.dashboard-main { + padding: 2rem; + max-width: 960px; + margin: 0 auto; +} + +.dashboard-main p { + margin: 0 0 1.5rem; + color: #475569; } diff --git a/src/App.tsx b/src/App.tsx index a66b5ef..c5fa7f9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,122 +1,29 @@ -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 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..05589fb --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,215 @@ +import type { + Cliente, + ClienteFormInput, + Consegna, + ConsegnaFormInput, + Ruolo, + Utente, + UtenteAdmin, + UtenteAdminFormInput, + VerificaConsegna, +} from "../types"; + +const API_URL = import.meta.env.VITE_API_URL ?? ""; + +export class ApiError extends Error { + status: number; + fields?: Record; + + constructor(message: string, status: number, fields?: Record) { + super(message); + this.name = "ApiError"; + this.status = status; + this.fields = fields; + } +} + +interface ApiErrorBody { + error?: string; + fields?: Record; +} + +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, + }); + + if (response.status === 204) { + return undefined as T; + } + + const data = (await response.json().catch(() => ({}))) as ApiErrorBody; + + if (!response.ok) { + throw new ApiError( + data.error ?? `Errore ${response.status}`, + response.status, + data.fields + ); + } + + return data as T; +} + +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: 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, + }); +} + +export async function getMe(): Promise { + const data = await request<{ + id: number; + email: string; + name: string; + ruolo: Ruolo; + }>("/api/me"); + + const [nome, ...cognomeParts] = (data.name ?? "").split(" "); + + return { + id: data.id, + nome, + cognome: cognomeParts.join(" "), + email: data.email, + ruolo: data.ruolo, + }; +} + +export async function verificaConsegna( + chiave: string +): Promise { + const params = new URLSearchParams({ chiave: chiave.trim().toUpperCase() }); + return request(`/api/verifica-consegna?${params.toString()}`); +} + +export async function getClienti(): Promise { + return request("/api/clienti"); +} + +export async function createCliente(data: ClienteFormInput): Promise { + return request("/api/clienti", { + method: "POST", + body: JSON.stringify(data), + }); +} + +export async function updateCliente( + id: number, + data: Partial +): Promise { + return request(`/api/clienti/${id}`, { + method: "PUT", + body: JSON.stringify(data), + }); +} + +export async function deleteCliente(id: number): Promise { + await request(`/api/clienti/${id}`, { method: "DELETE" }); +} + +export async function getConsegne(): Promise { + return request("/api/consegne"); +} + +export async function createConsegna( + data: ConsegnaFormInput +): Promise { + return request("/api/consegne", { + method: "POST", + body: JSON.stringify(data), + }); +} + +export async function updateConsegna( + id: number, + data: Partial +): Promise { + return request(`/api/consegne/${id}`, { + method: "PUT", + body: JSON.stringify(data), + }); +} + +export async function deleteConsegna(id: number): Promise { + await request(`/api/consegne/${id}`, { method: "DELETE" }); +} + +export async function getUtenti(): Promise { + return request("/api/utenti"); +} + +export async function createUtente( + data: UtenteAdminFormInput +): Promise { + return request("/api/utenti", { + method: "POST", + body: JSON.stringify(data), + }); +} + +export async function updateUtente( + id: number, + data: Partial +): Promise { + return request(`/api/utenti/${id}`, { + method: "PUT", + body: JSON.stringify(data), + }); +} + +export async function deleteUtente(id: number): Promise { + await request(`/api/utenti/${id}`, { method: "DELETE" }); +} 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/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..70c9f66 --- /dev/null +++ b/src/components/ProtectedRoute.tsx @@ -0,0 +1,26 @@ +import { Navigate } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; + +export default function ProtectedRoute({ + children, + adminOnly = false, +}: { + children: React.ReactNode; + adminOnly?: boolean; +}) { + const { user, loading } = useAuth(); + + if (loading) return

Caricamento...

; + if (!user) return ; + if (adminOnly && user.ruolo !== "Amministratore") { + 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/pages/Dashboard.tsx b/src/pages/Dashboard.tsx new file mode 100644 index 0000000..ef31cd7 --- /dev/null +++ b/src/pages/Dashboard.tsx @@ -0,0 +1,25 @@ +import { useAuth } from "../context/AuthContext"; + +export default function Dashboard() { + const { user, logout } = useAuth(); + + return ( +
+
+
+

Dashboard

+

+ Benvenuto, {user?.nome} {user?.cognome} + {user?.ruolo === "Amministratore" ? " (Amministratore)" : ""} +

+
+ +
+
+

Area riservata agli operatori corrieri.

+
+
+ ); +} diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx new file mode 100644 index 0000000..f9c0340 --- /dev/null +++ b/src/pages/Login.tsx @@ -0,0 +1,70 @@ +import { useState, type FormEvent } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; +import { UTENTE_CONSTRAINTS } from "../validation/constraints"; +import { validateLoginInput } from "../validation/utente"; + +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); + + const validation = validateLoginInput({ email, password }); + if (!validation.success) { + setError(Object.values(validation.errors)[0] ?? "Dati di accesso non validi"); + return; + } + + setLoading(true); + try { + await login(validation.data.email, validation.data.password); + navigate("/dashboard"); + } catch (err) { + setError(err instanceof Error ? err.message : "Errore di accesso"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Gestione Corrieri

+

Accedi con le tue credenziali

+ {error &&

{error}

} + + + +
+
+ ); +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..b7849e3 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,70 @@ +export type Ruolo = "Operatore" | "Amministratore"; + +export interface Utente { + id: number; + nome: string; + cognome: string; + email: string; + ruolo: Ruolo; +} + +export interface Cliente { + id: number; + nome: string; + cognome: string; + via: string; + comune: string; + provincia: string; + telefono: string; + email: string; + note: string | null; +} + +export type ClienteFormInput = Omit; + +export type StatoConsegna = + | "da ritirare" + | "in consegna" + | "in giacenza" + | "in deposito"; + +export interface Consegna { + id: number; + dataRitiro: string; + dataConsegna: string; + stato: StatoConsegna; + chiaveConsegna: string; + clienteId: number; + clienteNome: string; + clienteCognome: string; +} + +export type ConsegnaFormInput = Omit< + Consegna, + "id" | "clienteNome" | "clienteCognome" +>; + +export interface UtenteAdmin { + id: number; + nome: string; + cognome: string; + email: string; + ruolo: Ruolo; +} + +export interface UtenteAdminFormInput { + nome: string; + cognome: string; + email: string; + password: string; + ruolo: Ruolo; +} + +export interface VerificaConsegna { + dataRitiro: string; + dataConsegna: string; + stato: StatoConsegna; + clienteNome: string; + clienteCognome: string; + comune: string; +}