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
+13
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { Eye } from "lucide-react";
import type { ClientWithPayments } from "@/lib/admin-queries";
import { CopyLinkButton } from "@/components/admin/CopyLinkButton";
@@ -67,6 +68,18 @@ export function ClientRow({ client }: { client: ClientWithPayments }) {
/client/{slug.slice(0, 12)}
</a>
<CopyLinkButton path={path} title="Copia link profilo" />
{/* Anteprima admin: apre il portale saltando il gate OTP, in sola
lettura. Vedi getClientGate() in src/lib/client-gate.ts. */}
<a
href={`${path}?preview=1`}
target="_blank"
rel="noopener noreferrer"
title="Anteprima portale (sola lettura)"
aria-label="Anteprima portale (sola lettura)"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<Eye className="h-4 w-4" />
</a>
</div>
</td>
</tr>
+6 -1
View File
@@ -11,6 +11,7 @@ import { OffersSection } from './client/OffersSection';
import { MilestoneStepper } from './client/MilestoneStepper';
import { ChatProvider } from './client/ChatProvider';
import { ChatPanel } from './client/ChatPanel';
import { PreviewProvider } from './client/PreviewProvider';
interface ClientDashboardProps {
view: ClientView;
@@ -19,9 +20,11 @@ interface ClientDashboardProps {
/** When rendered inside the multi-project tabs wrapper, the page already
* provides the portal header + footer — skip them here to avoid duplicates. */
embedded?: boolean;
/** Anteprima admin: sola lettura, approvazioni e chat disattivate. */
preview?: boolean;
}
export function ClientDashboard({ view, token, comments, embedded = false }: ClientDashboardProps) {
export function ClientDashboard({ view, token, comments, embedded = false, preview = false }: ClientDashboardProps) {
// Determine payment display mode based on active offers.
// Solo i retainer ATTIVI cambiano la modalità: uno sospeso continuerebbe
// altrimenti a intestare i pagamenti "Totale Pagamento Mensile" e a
@@ -38,6 +41,7 @@ export function ClientDashboard({ view, token, comments, embedded = false }: Cli
: undefined;
return (
<PreviewProvider preview={preview}>
<ChatProvider phases={view.phases} clientId={view.client.id}>
<div className={embedded ? "" : "min-h-screen bg-background"}>
{/* Header portale — iamcavalli · Client Portal | brand | area protetta */}
@@ -159,5 +163,6 @@ export function ClientDashboard({ view, token, comments, embedded = false }: Cli
<ChatPanel token={token} comments={comments} />
</div>
</ChatProvider>
</PreviewProvider>
);
}
+7 -1
View File
@@ -3,6 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { usePreview } from "@/components/client/PreviewProvider";
type Props = {
deliverableId: string;
@@ -12,6 +13,7 @@ type Props = {
export function ApproveButton({ deliverableId, token, approvedAt }: Props) {
const router = useRouter();
const preview = usePreview();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -30,6 +32,9 @@ export function ApproveButton({ deliverableId, token, approvedAt }: Props) {
}
async function handleApprove() {
// approved_at è immutabile una volta impostato: in anteprima admin un click
// distratto non deve poter approvare al posto del cliente.
if (preview) return;
setLoading(true);
setError(null);
try {
@@ -57,7 +62,8 @@ export function ApproveButton({ deliverableId, token, approvedAt }: Props) {
size="sm"
variant="outline"
onClick={handleApprove}
disabled={loading}
disabled={loading || preview}
title={preview ? "Disattivato in anteprima admin" : undefined}
className="text-xs text-green-700 border-green-300 hover:bg-green-50"
>
{loading ? "Approvazione..." : "Approva"}
+13 -1
View File
@@ -4,6 +4,7 @@ import { useState, useTransition, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import type { Comment } from "@/db/schema";
import { useChatContext } from "./ChatProvider";
import { usePreview } from "./PreviewProvider";
// TODO: Email-on-tag (admin→client notification when admin posts on a phase/task) is OUT OF SCOPE
// Hook point: after db.insert(comments) in /src/app/api/client/comment/route.ts and
@@ -26,6 +27,7 @@ interface ChatPanelProps {
export function ChatPanel({ token, comments }: ChatPanelProps) {
const { isOpen, selectedPhaseId, phases, clientId, openChat, closeChat } = useChatContext();
const preview = usePreview();
const [body, setBody] = useState("");
const [error, setError] = useState<string | null>(null);
const [, startTransition] = useTransition();
@@ -220,7 +222,16 @@ export function ChatPanel({ token, comments }: ChatPanelProps) {
{/* Divider */}
<div className="border-t border-[#e5e7eb]" />
{/* Composer */}
{/* Composer — in anteprima admin lo storico resta visibile (serve proprio
a vedere cosa legge il cliente) ma non si può scrivere al posto suo.
Colori in hex come nel resto del pannello: questo componente è
un'isola forzata a bg-white, i token semantici qui renderebbero
testo chiaro su fondo chiaro in dark mode. */}
{preview ? (
<div className="p-3 bg-[#fafafa] shrink-0 text-center text-xs text-[#71717a]">
Composer disattivato in anteprima
</div>
) : (
<form onSubmit={handleSend} className="p-3 space-y-2 bg-[#fafafa] shrink-0">
{/* Tag selector: Generale + phases */}
<select
@@ -259,6 +270,7 @@ export function ChatPanel({ token, comments }: ChatPanelProps) {
</div>
{error && <p className="text-xs text-red-600">{error}</p>}
</form>
)}
</div>
</>
);
+29
View File
@@ -0,0 +1,29 @@
import Link from "next/link";
import { Eye } from "lucide-react";
/**
* Barra di contesto dell'anteprima admin.
*
* Non è decorativa: senza, l'anteprima è indistinguibile dal portale vero e i
* pulsanti disattivati sembrano un bug del portale invece di una scelta.
*
* Volutamente NON sticky: l'header del portale è già `sticky top-0` e le due
* barre si coprirebbero a vicenda. L'anteprima si apre in una scheda nuova,
* quindi la via d'uscita naturale è chiudere la scheda.
*/
export function PreviewBanner({ brandName }: { brandName: string }) {
return (
<div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b border-border bg-muted px-4 py-2 text-center text-xs text-muted-foreground">
<span className="flex items-center gap-2 font-medium text-foreground">
<Eye className="h-3.5 w-3.5" />
Anteprima admin
</span>
<span>
Stai vedendo il portale di {brandName} come lo vede il cliente. Sola lettura.
</span>
<Link href="/admin/clients" className="font-medium text-foreground underline underline-offset-2 hover:no-underline">
Esci
</Link>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { createContext, useContext } from "react";
const PreviewContext = createContext(false);
/**
* `true` quando il portale è aperto in anteprima admin (`?preview=1`).
*
* A differenza di useChatContext non lancia fuori dal provider: i consumatori
* (ApproveButton, ChatPanel) vivono anche nel portale vero, dove il provider
* c'è ma vale false, e il default deve semplicemente essere "non è anteprima".
*
* Context e non prop drilling: ApproveButton sta quattro livelli sotto la
* dashboard (PhaseViewToggle → PhaseTimeline → PhaseCard, più il ramo kanban)
* e passare un booleano lungo quella catena sporcherebbe cinque firme per un
* dettaglio che riguarda due foglie.
*/
export function usePreview(): boolean {
return useContext(PreviewContext);
}
export function PreviewProvider({
children,
preview,
}: {
children: React.ReactNode;
preview: boolean;
}) {
return <PreviewContext.Provider value={preview}>{children}</PreviewContext.Provider>;
}