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:
+27
-7
@@ -3,9 +3,9 @@ gsd_state_version: 1.0
|
|||||||
milestone: v2.3
|
milestone: v2.3
|
||||||
milestone_name: Email & Accesso
|
milestone_name: Email & Accesso
|
||||||
status: executing
|
status: executing
|
||||||
stopped_at: "v2.3 deployata in produzione; resta da popolare la whitelist dei 3 clienti reali"
|
stopped_at: "anteprima admin del portale + occhiolino login: scritti, buildati e verificati in locale, NON ancora committati né deployati. Resta da popolare la whitelist dei 3 clienti reali"
|
||||||
last_updated: "2026-07-29T21:20:00.000Z"
|
last_updated: "2026-08-08T12:20:00.000Z"
|
||||||
last_activity: 2026-07-29 -- dominio Resend verificato, gate OTP deployato in produzione
|
last_activity: 2026-08-08 -- anteprima admin in sola lettura del portale cliente + toggle password sul login
|
||||||
progress:
|
progress:
|
||||||
total_phases: 3
|
total_phases: 3
|
||||||
completed_phases: 3
|
completed_phases: 3
|
||||||
@@ -26,10 +26,30 @@ See: .planning/PROJECT.md (updated 2026-06-21)
|
|||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: 23/24/25 completate e deployate
|
Phase: v2.3 (23/24/25) e v2.4 Phase 13 in produzione. Sopra, lavoro **non ancora committato**: anteprima admin del portale + occhiolino sul login.
|
||||||
Plan: `~/.claude/plans/si-ma-abbiamo-un-jaunty-micali.md`
|
Plan: `~/.claude/plans/per-fare-l-accesso-all-admin-elegant-teapot.md`
|
||||||
Status: in produzione. Resta da popolare la whitelist dei 3 clienti reali.
|
Status: scritto, buildato, verificato in locale contro il DB di produzione. **Non committato, non deployato.**
|
||||||
Last activity: 2026-07-29 — dominio Resend verificato, deploy su `hub.iamcavalli.net`
|
Last activity: 2026-08-08 — anteprima admin in sola lettura del portale cliente
|
||||||
|
|
||||||
|
### 2026-08-08 — Anteprima admin del portale + occhiolino sul login
|
||||||
|
|
||||||
|
**Non è in produzione.** Codice scritto e verificato in locale (`npm run build` verde, nessun nuovo problema di lint), nessun commit e nessun push fatti.
|
||||||
|
|
||||||
|
**Cosa risolve.** Quando un cliente diceva "non trovo una cosa" non c'era modo di guardare il portale con i suoi occhi: il gate OTP lascia entrare solo lui. Ora l'elenco clienti (`ClientRow`) ha un'icona che apre `/client/<slug>?preview=1` in una scheda nuova.
|
||||||
|
|
||||||
|
**Come funziona.** `getClientGate()` accetta `{ previewRequested }` e salta il gate solo se il query param c'è **e** `getServerSession(authOptions)` è valida. Ritorna `preview: true` senza sintetizzare una `ClientSession`: un admin in anteprima non è un cliente autenticato, e confondere i due stati renderebbe i casi indistinguibili a valle. La sola lettura passa da `PreviewProvider` (`usePreview()`), consumato da `ApproveButton` e dal composer di `ChatPanel` — context e non prop drilling, perché `ApproveButton` sta quattro livelli sotto la dashboard.
|
||||||
|
|
||||||
|
**Perché sola lettura e non anteprima interattiva.** Il portale scrive davvero: `/api/client/approve` e `/api/client/comment` autenticano sul token nel body, non sulla sessione. Un click distratto approverebbe un deliverable, e `approved_at` è immutabile una volta impostato (vincolo LOCKED #3). La protezione è a livello di UI, non di API: un admin può ancora chiamare le route a mano. È voluto — l'obiettivo è impedire l'incidente, non difendersi da sé stessi.
|
||||||
|
|
||||||
|
**Deviazione consapevole dal vincolo LOCKED #4** (`/client/*` → token, `/admin/*` → Auth.js): ora una route client legge anche la sessione Auth.js. Non indebolisce nulla — per i clienti il gate OTP è identico — ma `CLAUDE.md` **non è stato ancora aggiornato**: serve l'ok dell'utente per toccare la sezione LOCKED.
|
||||||
|
|
||||||
|
**Verificato in locale** (build di produzione su :3100, DB di prod via tunnel SSH, sole letture): `?preview=1` **senza** sessione admin → gate OTP; con cookie di sessione contraffatto → gate OTP; `?preview=0`, `?preview=abc`, `?preview=` → gate OTP; senza query param e **con** sessione admin → gate OTP (nessuna regressione); con sessione admin valida + `?preview=1` → portale con banner e composer disattivato, sia sul cliente a progetto singolo sia su quello a due progetti (tab).
|
||||||
|
|
||||||
|
**Non verificato dal vivo:** il ramo `ApproveButton` — in produzione la tabella `deliverables` è **vuota** (0 righe), quindi quel pulsante oggi non si renderizza mai. Wiring controllato solo a livello di codice. Il click dell'occhiolino sul login è verificato solo nel markup renderizzato (`type="button"`, `tabindex="-1"`, `aria-label`): manca la prova a schermo, Playwright non ha browser in cache.
|
||||||
|
|
||||||
|
**Perché la password di `.env.local` non funzionava.** Non era un bug: `ADMIN_PASSWORD` è stata ruotata il 2026-07-28 **solo su Coolify** (vedi `SECURITY-REMEDIATION-PLAN.md`), e `.env.local` è rimasto alla precedente. Vale anche per `NEXTAUTH_SECRET`. **`.env.local` non è allineato a produzione** — non trattarlo come fonte di verità per le credenziali.
|
||||||
|
|
||||||
|
**Nota su `.env.local`:** contiene due righe `DATABASE_URL`; vince l'ultima (porta 54321) e punta al DB di produzione. Il DB non è raggiungibile dall'esterno: per puntarci in locale serve `ssh -f -N -L 54321:localhost:54321 root@178.104.27.55` **e** sostituire l'host con `localhost` nella stringa di connessione.
|
||||||
|
|
||||||
### ✅ Prerequisiti email risolti (2026-07-29)
|
### ✅ Prerequisiti email risolti (2026-07-29)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { getClientGate } from "@/lib/client-gate";
|
import { getClientGate } from "@/lib/client-gate";
|
||||||
import { ClientDashboard } from "@/components/client-dashboard";
|
import { ClientDashboard } from "@/components/client-dashboard";
|
||||||
import { OtpGate } from "@/components/client/OtpGate";
|
import { OtpGate } from "@/components/client/OtpGate";
|
||||||
|
import { PreviewBanner } from "@/components/client/PreviewBanner";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import type { Comment } from "@/db/schema";
|
import type { Comment } from "@/db/schema";
|
||||||
|
|
||||||
@@ -96,16 +97,21 @@ export async function generateMetadata({
|
|||||||
|
|
||||||
export default async function ClientPage({
|
export default async function ClientPage({
|
||||||
params,
|
params,
|
||||||
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ token: string }>;
|
params: Promise<{ token: string }>;
|
||||||
|
searchParams: Promise<{ preview?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { token } = await params;
|
const { token } = await params;
|
||||||
|
const { preview: previewParam } = await searchParams;
|
||||||
|
|
||||||
// ⚠️ Il gate va PRIMA di ogni query sui dati del progetto: se si interroga il
|
// ⚠️ 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
|
// 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.
|
// dell'HTML anche se non compaiono a schermo. Vedi src/lib/client-gate.ts.
|
||||||
const { client: identity, session } = await getClientGate(token);
|
const { client: identity, session, preview } = await getClientGate(token, {
|
||||||
if (identity && !session) {
|
previewRequested: previewParam === "1",
|
||||||
|
});
|
||||||
|
if (identity && !session && !preview) {
|
||||||
return <OtpGate token={token} brandName={identity.brand_name} />;
|
return <OtpGate token={token} brandName={identity.brand_name} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,14 +120,19 @@ export default async function ClientPage({
|
|||||||
|
|
||||||
const { client, projects } = clientData;
|
const { client, projects } = clientData;
|
||||||
|
|
||||||
|
const banner = preview ? <PreviewBanner brandName={client.brand_name} /> : null;
|
||||||
|
|
||||||
if (projects.length === 0) {
|
if (projects.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{banner}
|
||||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-xl font-bold text-foreground">{client.name}</h1>
|
<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>
|
<p className="text-sm text-muted-foreground mt-2">Nessun progetto disponibile al momento.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,11 +141,15 @@ export default async function ClientPage({
|
|||||||
const view = await getProjectView(projects[0].id);
|
const view = await getProjectView(projects[0].id);
|
||||||
if (!view) notFound();
|
if (!view) notFound();
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{banner}
|
||||||
<ClientDashboard
|
<ClientDashboard
|
||||||
view={projectViewToClientView(client, view)}
|
view={projectViewToClientView(client, view)}
|
||||||
token={client.token}
|
token={client.token}
|
||||||
comments={view.comments as unknown as Comment[]}
|
comments={view.comments as unknown as Comment[]}
|
||||||
|
preview={preview}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +158,7 @@ export default async function ClientPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<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">
|
<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">
|
<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>
|
<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}
|
token={client.token}
|
||||||
comments={view.comments as unknown as Comment[]}
|
comments={view.comments as unknown as Comment[]}
|
||||||
embedded
|
embedded
|
||||||
|
preview={preview}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">Progetto non disponibile.</p>
|
<p className="text-sm text-muted-foreground">Progetto non disponibile.</p>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { Eye } from "lucide-react";
|
||||||
import type { ClientWithPayments } from "@/lib/admin-queries";
|
import type { ClientWithPayments } from "@/lib/admin-queries";
|
||||||
import { CopyLinkButton } from "@/components/admin/CopyLinkButton";
|
import { CopyLinkButton } from "@/components/admin/CopyLinkButton";
|
||||||
|
|
||||||
@@ -67,6 +68,18 @@ export function ClientRow({ client }: { client: ClientWithPayments }) {
|
|||||||
/client/{slug.slice(0, 12)}…
|
/client/{slug.slice(0, 12)}…
|
||||||
</a>
|
</a>
|
||||||
<CopyLinkButton path={path} title="Copia link profilo" />
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { OffersSection } from './client/OffersSection';
|
|||||||
import { MilestoneStepper } from './client/MilestoneStepper';
|
import { MilestoneStepper } from './client/MilestoneStepper';
|
||||||
import { ChatProvider } from './client/ChatProvider';
|
import { ChatProvider } from './client/ChatProvider';
|
||||||
import { ChatPanel } from './client/ChatPanel';
|
import { ChatPanel } from './client/ChatPanel';
|
||||||
|
import { PreviewProvider } from './client/PreviewProvider';
|
||||||
|
|
||||||
interface ClientDashboardProps {
|
interface ClientDashboardProps {
|
||||||
view: ClientView;
|
view: ClientView;
|
||||||
@@ -19,9 +20,11 @@ interface ClientDashboardProps {
|
|||||||
/** When rendered inside the multi-project tabs wrapper, the page already
|
/** When rendered inside the multi-project tabs wrapper, the page already
|
||||||
* provides the portal header + footer — skip them here to avoid duplicates. */
|
* provides the portal header + footer — skip them here to avoid duplicates. */
|
||||||
embedded?: boolean;
|
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.
|
// Determine payment display mode based on active offers.
|
||||||
// Solo i retainer ATTIVI cambiano la modalità: uno sospeso continuerebbe
|
// Solo i retainer ATTIVI cambiano la modalità: uno sospeso continuerebbe
|
||||||
// altrimenti a intestare i pagamenti "Totale Pagamento Mensile" e a
|
// altrimenti a intestare i pagamenti "Totale Pagamento Mensile" e a
|
||||||
@@ -38,6 +41,7 @@ export function ClientDashboard({ view, token, comments, embedded = false }: Cli
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<PreviewProvider preview={preview}>
|
||||||
<ChatProvider phases={view.phases} clientId={view.client.id}>
|
<ChatProvider phases={view.phases} clientId={view.client.id}>
|
||||||
<div className={embedded ? "" : "min-h-screen bg-background"}>
|
<div className={embedded ? "" : "min-h-screen bg-background"}>
|
||||||
{/* Header portale — iamcavalli · Client Portal | brand | area protetta */}
|
{/* 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} />
|
<ChatPanel token={token} comments={comments} />
|
||||||
</div>
|
</div>
|
||||||
</ChatProvider>
|
</ChatProvider>
|
||||||
|
</PreviewProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { usePreview } from "@/components/client/PreviewProvider";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
deliverableId: string;
|
deliverableId: string;
|
||||||
@@ -12,6 +13,7 @@ type Props = {
|
|||||||
|
|
||||||
export function ApproveButton({ deliverableId, token, approvedAt }: Props) {
|
export function ApproveButton({ deliverableId, token, approvedAt }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const preview = usePreview();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -30,6 +32,9 @@ export function ApproveButton({ deliverableId, token, approvedAt }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleApprove() {
|
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);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -57,7 +62,8 @@ export function ApproveButton({ deliverableId, token, approvedAt }: Props) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleApprove}
|
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"
|
className="text-xs text-green-700 border-green-300 hover:bg-green-50"
|
||||||
>
|
>
|
||||||
{loading ? "Approvazione..." : "Approva"}
|
{loading ? "Approvazione..." : "Approva"}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useTransition, useRef, useEffect } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { Comment } from "@/db/schema";
|
import type { Comment } from "@/db/schema";
|
||||||
import { useChatContext } from "./ChatProvider";
|
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
|
// 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
|
// 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) {
|
export function ChatPanel({ token, comments }: ChatPanelProps) {
|
||||||
const { isOpen, selectedPhaseId, phases, clientId, openChat, closeChat } = useChatContext();
|
const { isOpen, selectedPhaseId, phases, clientId, openChat, closeChat } = useChatContext();
|
||||||
|
const preview = usePreview();
|
||||||
const [body, setBody] = useState("");
|
const [body, setBody] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
@@ -220,7 +222,16 @@ export function ChatPanel({ token, comments }: ChatPanelProps) {
|
|||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="border-t border-[#e5e7eb]" />
|
<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">
|
<form onSubmit={handleSend} className="p-3 space-y-2 bg-[#fafafa] shrink-0">
|
||||||
{/* Tag selector: Generale + phases */}
|
{/* Tag selector: Generale + phases */}
|
||||||
<select
|
<select
|
||||||
@@ -259,6 +270,7 @@ export function ChatPanel({ token, comments }: ChatPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
+25
-6
@@ -1,11 +1,13 @@
|
|||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
import { getClientIdentityByToken, type ClientIdentity } from "@/lib/client-view";
|
import { getClientIdentityByToken, type ClientIdentity } from "@/lib/client-view";
|
||||||
import { sessionCookieName, verifySessionValue, type ClientSession } from "@/lib/client-session";
|
import { sessionCookieName, verifySessionValue, type ClientSession } from "@/lib/client-session";
|
||||||
|
|
||||||
export type GateResult =
|
export type GateResult =
|
||||||
| { client: null; session: null }
|
| { client: null; session: null; preview: false }
|
||||||
| { client: ClientIdentity; session: ClientSession | null };
|
| { client: ClientIdentity; session: ClientSession | null; preview: boolean };
|
||||||
|
|
||||||
// cache(): layout e page risolvono lo stesso cliente nella stessa richiesta
|
// cache(): layout e page risolvono lo stesso cliente nella stessa richiesta
|
||||||
// senza fare due giri di query.
|
// senza fare due giri di query.
|
||||||
@@ -15,17 +17,34 @@ const resolveClient = cache(getClientIdentityByToken);
|
|||||||
* Stato di accesso al portale per un token/slug.
|
* Stato di accesso al portale per un token/slug.
|
||||||
*
|
*
|
||||||
* ⚠️ Va chiamata all'INIZIO della page, PRIMA di qualsiasi query sui dati del
|
* ⚠️ Va chiamata all'INIZIO della page, PRIMA di qualsiasi query sui dati del
|
||||||
* progetto — e la page deve tornare il gate se `session` è null.
|
* progetto — e la page deve tornare il gate se `session` è null E `preview` è
|
||||||
|
* false.
|
||||||
*
|
*
|
||||||
* Metterla solo nel layout NON basta e non è una svista: nell'App Router il
|
* Metterla solo nel layout NON basta e non è una svista: nell'App Router il
|
||||||
* segmento `page` viene renderizzato in parallelo al layout, quindi un layout
|
* segmento `page` viene renderizzato in parallelo al layout, quindi un layout
|
||||||
* che non renderizza `{children}` nasconde la dashboard a schermo ma la sua
|
* che non renderizza `{children}` nasconde la dashboard a schermo ma la sua
|
||||||
* query è già partita e il payload RSC finisce comunque nell'HTML. Verificato:
|
* query è già partita e il payload RSC finisce comunque nell'HTML. Verificato:
|
||||||
* fasi, task e pagamenti erano leggibili nel sorgente della pagina di accesso.
|
* fasi, task e pagamenti erano leggibili nel sorgente della pagina di accesso.
|
||||||
|
*
|
||||||
|
* `previewRequested` abilita l'anteprima admin (`?preview=1`): un admin con
|
||||||
|
* sessione Auth.js valida vede il portale senza passare dal gate OTP, per
|
||||||
|
* controllare cosa vede il cliente quando segnala che non trova qualcosa.
|
||||||
|
* È un opt-in esplicito e non un bypass silenzioso: senza query param anche
|
||||||
|
* l'admin vede il gate OTP, così il gate resta testabile dal vivo.
|
||||||
*/
|
*/
|
||||||
export async function getClientGate(tokenOrSlug: string): Promise<GateResult> {
|
export async function getClientGate(
|
||||||
|
tokenOrSlug: string,
|
||||||
|
opts?: { previewRequested?: boolean }
|
||||||
|
): Promise<GateResult> {
|
||||||
const client = await resolveClient(tokenOrSlug);
|
const client = await resolveClient(tokenOrSlug);
|
||||||
if (!client) return { client: null, session: null };
|
if (!client) return { client: null, session: null, preview: false };
|
||||||
|
|
||||||
|
if (opts?.previewRequested && (await getServerSession(authOptions)) !== null) {
|
||||||
|
// Nessuna ClientSession sintetica: l'admin in anteprima non è un cliente
|
||||||
|
// autenticato, e confondere i due stati renderebbe i due casi
|
||||||
|
// indistinguibili a valle (es. per la sola lettura).
|
||||||
|
return { client, session: null, preview: true };
|
||||||
|
}
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const session = await verifySessionValue(
|
const session = await verifySessionValue(
|
||||||
@@ -39,5 +58,5 @@ export async function getClientGate(tokenOrSlug: string): Promise<GateResult> {
|
|||||||
client.sessions_valid_from !== null &&
|
client.sessions_valid_from !== null &&
|
||||||
session.iat < client.sessions_valid_from.getTime();
|
session.iat < client.sessions_valid_from.getTime();
|
||||||
|
|
||||||
return { client, session: revoked ? null : session };
|
return { client, session: revoked ? null : session, preview: false };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user