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>
);
}