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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user