feat(client): anteprima admin in sola lettura del portale cliente

Quando un cliente segnalava "non trovo una cosa" non c'era modo di
guardare il portale con i suoi occhi: il gate OTP lascia entrare solo
lui. Dall'elenco clienti ora un'icona apre /client/<slug>?preview=1.

getClientGate() accetta { previewRequested } e salta il gate solo se il
query param c'è E getServerSession(authOptions) è valida. Senza param
anche un admin vede il gate OTP, così il gate resta testabile dal vivo.
Ritorna preview: true senza sintetizzare una ClientSession: un admin in
anteprima non è un cliente autenticato, e confondere i due stati li
renderebbe indistinguibili proprio dove serve distinguerli.

Sola lettura perché il portale scrive davvero: /api/client/approve e
/api/client/comment autenticano sul token nel body, non sulla sessione,
e deliverables.approved_at è immutabile una volta impostato (LOCKED #3).
La protezione è a livello di UI, non di API — impedisce l'incidente, non
difende da sé stessi. Il flag passa da PreviewProvider e non per prop
drilling: ApproveButton sta quattro livelli sotto la dashboard.

Deviazione consapevole dal vincolo LOCKED #4: una route client ora legge
anche la sessione Auth.js. CLAUDE.md non è aggiornato, la sezione LOCKED
richiede approvazione esplicita.

Verificato col build di produzione contro il DB reale (sole letture):
gate OTP senza sessione admin, con cookie contraffatto e con preview=0/
abc/vuoto; portale con banner e composer disattivato con sessione valida,
sia a progetto singolo sia a due progetti. Il ramo ApproveButton non è
esercitabile dal vivo: in produzione deliverables è vuota.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 14:19:22 +02:00
parent 09a5b1ff4f
commit 187550fedf
9 changed files with 180 additions and 28 deletions
+29 -12
View File
@@ -10,6 +10,7 @@ import {
import { getClientGate } from "@/lib/client-gate";
import { ClientDashboard } from "@/components/client-dashboard";
import { OtpGate } from "@/components/client/OtpGate";
import { PreviewBanner } from "@/components/client/PreviewBanner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { Comment } from "@/db/schema";
@@ -96,16 +97,21 @@ export async function generateMetadata({
export default async function ClientPage({
params,
searchParams,
}: {
params: Promise<{ token: string }>;
searchParams: Promise<{ preview?: string }>;
}) {
const { token } = await params;
const { preview: previewParam } = await searchParams;
// ⚠️ Il gate va PRIMA di ogni query sui dati del progetto: se si interroga il
// DB e poi si decide di mostrare il form, i dati sono già nel payload RSC
// dell'HTML anche se non compaiono a schermo. Vedi src/lib/client-gate.ts.
const { client: identity, session } = await getClientGate(token);
if (identity && !session) {
const { client: identity, session, preview } = await getClientGate(token, {
previewRequested: previewParam === "1",
});
if (identity && !session && !preview) {
return <OtpGate token={token} brandName={identity.brand_name} />;
}
@@ -114,14 +120,19 @@ export default async function ClientPage({
const { client, projects } = clientData;
const banner = preview ? <PreviewBanner brandName={client.brand_name} /> : null;
if (projects.length === 0) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<h1 className="text-xl font-bold text-foreground">{client.name}</h1>
<p className="text-sm text-muted-foreground mt-2">Nessun progetto disponibile al momento.</p>
<>
{banner}
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<h1 className="text-xl font-bold text-foreground">{client.name}</h1>
<p className="text-sm text-muted-foreground mt-2">Nessun progetto disponibile al momento.</p>
</div>
</div>
</div>
</>
);
}
@@ -130,11 +141,15 @@ export default async function ClientPage({
const view = await getProjectView(projects[0].id);
if (!view) notFound();
return (
<ClientDashboard
view={projectViewToClientView(client, view)}
token={client.token}
comments={view.comments as unknown as Comment[]}
/>
<>
{banner}
<ClientDashboard
view={projectViewToClientView(client, view)}
token={client.token}
comments={view.comments as unknown as Comment[]}
preview={preview}
/>
</>
);
}
@@ -143,6 +158,7 @@ export default async function ClientPage({
return (
<div className="min-h-screen bg-background">
{banner}
<header className="sticky top-0 z-50 flex flex-col items-center gap-4 border-b border-border-light bg-card px-6 py-5 shadow-card md:flex-row md:justify-between md:px-8">
<div className="flex w-full items-center gap-3 md:w-auto">
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">iamcavalli</span>
@@ -178,6 +194,7 @@ export default async function ClientPage({
token={client.token}
comments={view.comments as unknown as Comment[]}
embedded
preview={preview}
/>
) : (
<p className="text-sm text-muted-foreground">Progetto non disponibile.</p>