diff --git a/.planning/STATE.md b/.planning/STATE.md index 4d57c44..1f353ab 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,9 +3,9 @@ gsd_state_version: 1.0 milestone: v2.3 milestone_name: Email & Accesso status: executing -stopped_at: "v2.3 deployata in produzione; resta da popolare la whitelist dei 3 clienti reali" -last_updated: "2026-07-29T21:20:00.000Z" -last_activity: 2026-07-29 -- dominio Resend verificato, gate OTP deployato in produzione +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-08-08T12:20:00.000Z" +last_activity: 2026-08-08 -- anteprima admin in sola lettura del portale cliente + toggle password sul login progress: total_phases: 3 completed_phases: 3 @@ -26,10 +26,30 @@ See: .planning/PROJECT.md (updated 2026-06-21) ## Current Position -Phase: 23/24/25 completate e deployate -Plan: `~/.claude/plans/si-ma-abbiamo-un-jaunty-micali.md` -Status: in produzione. Resta da popolare la whitelist dei 3 clienti reali. -Last activity: 2026-07-29 — dominio Resend verificato, deploy su `hub.iamcavalli.net` +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/per-fare-l-accesso-all-admin-elegant-teapot.md` +Status: scritto, buildato, verificato in locale contro il DB di produzione. **Non committato, non deployato.** +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/?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) diff --git a/src/app/client/[token]/page.tsx b/src/app/client/[token]/page.tsx index 93e73bd..32c33cf 100644 --- a/src/app/client/[token]/page.tsx +++ b/src/app/client/[token]/page.tsx @@ -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 ; } @@ -114,14 +120,19 @@ export default async function ClientPage({ const { client, projects } = clientData; + const banner = preview ? : null; + if (projects.length === 0) { return ( -
-
-

{client.name}

-

Nessun progetto disponibile al momento.

+ <> + {banner} +
+
+

{client.name}

+

Nessun progetto disponibile al momento.

+
-
+ ); } @@ -130,11 +141,15 @@ export default async function ClientPage({ const view = await getProjectView(projects[0].id); if (!view) notFound(); return ( - + <> + {banner} + + ); } @@ -143,6 +158,7 @@ export default async function ClientPage({ return (
+ {banner}
iamcavalli @@ -178,6 +194,7 @@ export default async function ClientPage({ token={client.token} comments={view.comments as unknown as Comment[]} embedded + preview={preview} /> ) : (

Progetto non disponibile.

diff --git a/src/components/admin/ClientRow.tsx b/src/components/admin/ClientRow.tsx index 42b06a0..96c68bf 100644 --- a/src/components/admin/ClientRow.tsx +++ b/src/components/admin/ClientRow.tsx @@ -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)}… + {/* Anteprima admin: apre il portale saltando il gate OTP, in sola + lettura. Vedi getClientGate() in src/lib/client-gate.ts. */} + + +
diff --git a/src/components/client-dashboard.tsx b/src/components/client-dashboard.tsx index 398f732..57d461c 100644 --- a/src/components/client-dashboard.tsx +++ b/src/components/client-dashboard.tsx @@ -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 ( +
{/* Header portale — iamcavalli · Client Portal | brand | area protetta */} @@ -159,5 +163,6 @@ export function ClientDashboard({ view, token, comments, embedded = false }: Cli
+
); } diff --git a/src/components/client/ApproveButton.tsx b/src/components/client/ApproveButton.tsx index 18f58d1..c47ff01 100644 --- a/src/components/client/ApproveButton.tsx +++ b/src/components/client/ApproveButton.tsx @@ -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(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"} diff --git a/src/components/client/ChatPanel.tsx b/src/components/client/ChatPanel.tsx index d66a582..c376893 100644 --- a/src/components/client/ChatPanel.tsx +++ b/src/components/client/ChatPanel.tsx @@ -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(null); const [, startTransition] = useTransition(); @@ -220,7 +222,16 @@ export function ChatPanel({ token, comments }: ChatPanelProps) { {/* Divider */}
- {/* 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 ? ( +
+ Composer disattivato in anteprima +
+ ) : (
{/* Tag selector: Generale + phases */}