feat(auth): gate OTP email sul portale cliente (v2.3 Phases 23-25)

Il portale /client/<slug> era protetto dal solo token in URL: chiunque
ricevesse o intercettasse il link entrava, per sempre, senza identificarsi.
Ora l'admin registra le email autorizzate per cliente e il cliente si
identifica con un codice usa-e-getta prima di vedere qualsiasi dato.

- Resend 6.18.1 + src/lib/mailer.ts (Result tipizzato, mai catch silenzioso)
- migration 0015 (gia applicata a prod): client_emails, otp_codes,
  clients.sessions_valid_from. Additiva pura, conteggi verificati pre/post
- admin: sezione "Accessi al portale" in /admin/clients/[id] con whitelist
  e revoca sessioni in blocco
- gate: codice 6 cifre CSPRNG, hash SHA-256 (mai il codice in chiaro),
  TTL 15 min, max 5 tentativi, rate limit su entrambi gli endpoint,
  risposta identica per email in whitelist e non (no enumeration)
- sessione: cookie HMAC per-cliente, 90 giorni, httpOnly/secure/lax

Il gate sta in cima alla page, NON nel layout: nell'App Router il segmento
page viene renderizzato in parallelo al layout, quindi gattare nel layout
nascondeva la dashboard a schermo ma lasciava fasi, task e pagamenti nel
payload RSC dell'HTML (46907 byte -> 17594 dopo il fix). Verificato.

Verifica: build OK, 9/9 test E2E in locale contro il DB di produzione.

NON DEPLOYARE prima di: RESEND_API_KEY+RESEND_FROM su Coolify e whitelist
popolata per i 3 clienti reali (oggi vuota) - altrimenti il gate li chiude
fuori dal loro portale. Checklist in .planning/STATE.md.

SEND-01/02 (invio preventivo via email) rinviati a v2.4 su richiesta.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 12:10:33 +02:00
parent b27b9d07ac
commit 8158038145
25 changed files with 1237 additions and 60 deletions
@@ -0,0 +1,148 @@
"use client";
// Gestione della whitelist email che apre il portale di un cliente (gate OTP v2.3).
// Scritto a token semantici anche se la pagina che lo ospita è ancora a palette
// vecchia: quando /admin/clients/[id] verrà rifatta, questa sezione non si tocca.
import { useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
addClientEmail,
removeClientEmail,
revokeClientSessions,
} from "@/app/admin/clients/[id]/actions";
import type { ClientAccessEmail } from "@/lib/admin-queries";
import { Button } from "@/components/ui/button";
export function ClientAccessSection({
clientId,
emails,
}: {
clientId: string;
emails: ClientAccessEmail[];
}) {
const [error, setError] = useState<string | null>(null);
const [revokeArmed, setRevokeArmed] = useState(false);
const [revokedAt, setRevokedAt] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const formRef = useRef<HTMLFormElement>(null);
const router = useRouter();
function handleAdd(formData: FormData) {
setError(null);
startTransition(async () => {
const res = await addClientEmail(clientId, formData);
if (!res.ok) {
setError(res.error);
return;
}
formRef.current?.reset();
router.refresh();
});
}
function handleRemove(emailId: string) {
startTransition(async () => {
await removeClientEmail(clientId, emailId);
router.refresh();
});
}
function handleRevoke() {
if (!revokeArmed) {
setRevokeArmed(true);
return;
}
startTransition(async () => {
await revokeClientSessions(clientId);
setRevokeArmed(false);
setRevokedAt(new Date().toLocaleString("it-IT"));
router.refresh();
});
}
return (
<section className="mt-8">
<div className="mb-3 flex items-end justify-between gap-4 flex-wrap">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Accessi al portale
</p>
<p className="mt-1 text-xs text-muted-foreground">
Solo queste email possono richiedere il codice di accesso. Chi ha il link ma
non è in elenco non entra.
</p>
</div>
{revokeArmed ? (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
Tutti dovranno rifare l&apos;accesso. Confermi?
</span>
<Button size="sm" variant="destructive" onClick={handleRevoke} disabled={isPending}>
, revoca
</Button>
<Button size="sm" variant="ghost" onClick={() => setRevokeArmed(false)}>
Annulla
</Button>
</div>
) : (
<Button size="sm" variant="outline" onClick={handleRevoke} disabled={isPending}>
Revoca sessioni attive
</Button>
)}
</div>
{revokedAt && (
<p className="mb-3 text-xs text-muted-foreground">
Sessioni revocate il {revokedAt}.
</p>
)}
<div className="rounded-xl border border-border bg-card">
{emails.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">
Nessuna email autorizzata il cliente non può ancora accedere al portale.
</p>
) : (
<ul className="divide-y divide-border">
{emails.map((e) => (
<li key={e.id} className="flex items-center justify-between gap-3 px-4 py-2.5">
<span className="min-w-0 truncate font-mono text-sm text-foreground">
{e.email}
</span>
<button
onClick={() => handleRemove(e.id)}
disabled={isPending}
className="shrink-0 text-xs text-muted-foreground underline-offset-2 hover:text-destructive hover:underline disabled:opacity-50"
>
Rimuovi
</button>
</li>
))}
</ul>
)}
<form
ref={formRef}
action={handleAdd}
className="flex items-center gap-2 border-t border-border px-4 py-3"
>
<input
type="email"
name="email"
required
placeholder="email@cliente.it"
autoComplete="off"
className="min-w-0 flex-1 rounded-lg border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-1 focus:ring-ring"
/>
<Button type="submit" size="sm" disabled={isPending}>
Aggiungi
</Button>
</form>
</div>
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
</section>
);
}
+154
View File
@@ -0,0 +1,154 @@
"use client";
// Schermata di accesso al portale cliente: email → codice a 6 cifre.
// Design system a token, dual light/dark. Non usa ui/dialog.tsx, che è ancora
// a palette raw e romperebbe il tema scuro.
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
type Step = "email" | "code";
export function OtpGate({ token, brandName }: { token: string; brandName: string }) {
const [step, setStep] = useState<Step>("email");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [notice, setNotice] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const router = useRouter();
async function requestCode(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const res = await fetch("/api/client/otp/request", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, email }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error ?? "Errore. Riprova.");
return;
}
// La risposta è neutra per costruzione: si passa allo step successivo
// anche se l'email non è autorizzata, altrimenti l'UI rivelerebbe la
// whitelist che l'API si è preoccupata di non rivelare.
setNotice(data.message);
setStep("code");
} catch {
setError("Connessione non riuscita. Riprova.");
} finally {
setLoading(false);
}
}
async function submitCode(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const res = await fetch("/api/client/otp/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, email, code }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error ?? "Codice non valido.");
return;
}
// Il cookie è già impostato dalla risposta: basta ricaricare e il layout
// lascerà passare.
router.refresh();
} catch {
setError("Connessione non riuscita. Riprova.");
} finally {
setLoading(false);
}
}
return (
<main className="flex min-h-screen items-center justify-center bg-background px-4 py-12">
<div className="w-full max-w-sm">
<p className="mb-1 text-sm text-muted-foreground">{brandName}</p>
<h1 className="mb-2 text-2xl font-semibold tracking-tight text-foreground">
Accedi al tuo portale
</h1>
{step === "email" ? (
<>
<p className="mb-6 text-sm leading-relaxed text-muted-foreground">
Inserisci l&apos;indirizzo email concordato: ti inviamo un codice per entrare.
</p>
<form onSubmit={requestCode} className="space-y-3">
<input
type="email"
value={email}
onChange={(ev) => setEmail(ev.target.value)}
required
autoFocus
autoComplete="email"
placeholder="nome@azienda.it"
className="w-full rounded-lg border border-border bg-card px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-1 focus:ring-ring"
/>
<Button type="submit" disabled={loading} className="w-full">
{loading ? "Invio…" : "Inviami il codice"}
</Button>
</form>
</>
) : (
<>
<p className="mb-6 text-sm leading-relaxed text-muted-foreground">{notice}</p>
<form onSubmit={submitCode} className="space-y-3">
<input
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
value={code}
onChange={(ev) => setCode(ev.target.value.replace(/\D/g, ""))}
required
autoFocus
autoComplete="one-time-code"
placeholder="000000"
className="w-full rounded-lg border border-border bg-card px-3 py-2.5 text-center font-mono text-lg tracking-[0.4em] text-foreground placeholder:tracking-[0.4em] placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-1 focus:ring-ring"
/>
<Button type="submit" disabled={loading || code.length !== 6} className="w-full">
{loading ? "Verifica…" : "Entra"}
</Button>
</form>
<button
onClick={() => {
setStep("email");
setCode("");
setError(null);
setNotice(null);
}}
className="mt-4 text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
Usa un altro indirizzo
</button>
</>
)}
{error && <p className="mt-4 text-sm text-destructive">{error}</p>}
<p className="mt-8 text-xs leading-relaxed text-muted-foreground">
Il codice scade dopo 15 minuti. Una volta entrato resti collegato per 90 giorni su
questo dispositivo.
</p>
</div>
</main>
);
}