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:
@@ -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'accesso. Confermi?
|
||||
</span>
|
||||
<Button size="sm" variant="destructive" onClick={handleRevoke} disabled={isPending}>
|
||||
Sì, 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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user