Compare commits

..

2 Commits

Author SHA1 Message Date
simone 187550fedf 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>
2026-08-08 14:19:22 +02:00
simone 09a5b1ff4f feat(auth): toggle mostra/nascondi password sul login admin
Il campo password non offriva modo di rileggere quanto digitato, quindi
un accesso fallito era indistinguibile da un errore di battitura.

Toggle inline e non nuovo primitivo in ui/: `type="password"` compare una
sola volta in tutto il codebase. type="button" perché dentro un <form> il
default è submit, e tabIndex -1 per tenere il Tab sulla sequenza campo →
Accedi. Classi a token semantici; gli hex literal preesistenti di questa
pagina restano da migrare a parte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 14:19:09 +02:00
10 changed files with 205 additions and 36 deletions
+27 -7
View File
@@ -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/<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)
+18 -1
View File
@@ -3,6 +3,7 @@
import { Suspense, useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import { Eye, EyeOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -15,6 +16,7 @@ function AdminLoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -53,14 +55,29 @@ function AdminLoginForm() {
</div>
<div className="space-y-1">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Input
id="password"
type="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
className="pr-10"
/>
{/* type="button": dentro un <form> il default è submit, e il toggle
invierebbe le credenziali a ogni click. tabIndex -1 tiene il Tab
sulla sequenza campo → Accedi. */}
<button
type="button"
tabIndex={-1}
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? "Nascondi password" : "Mostra password"}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{error && (
<p className="text-sm text-red-600">{error}</p>
+19 -2
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 (
<>
{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>
</>
);
}
@@ -130,11 +141,15 @@ export default async function ClientPage({
const view = await getProjectView(projects[0].id);
if (!view) notFound();
return (
<>
{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>
+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>;
}
+25 -6
View File
@@ -1,11 +1,13 @@
import { cache } from "react";
import { cookies } from "next/headers";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getClientIdentityByToken, type ClientIdentity } from "@/lib/client-view";
import { sessionCookieName, verifySessionValue, type ClientSession } from "@/lib/client-session";
export type GateResult =
| { client: null; session: null }
| { client: ClientIdentity; session: ClientSession | null };
| { client: null; session: null; preview: false }
| { client: ClientIdentity; session: ClientSession | null; preview: boolean };
// cache(): layout e page risolvono lo stesso cliente nella stessa richiesta
// senza fare due giri di query.
@@ -15,17 +17,34 @@ const resolveClient = cache(getClientIdentityByToken);
* Stato di accesso al portale per un token/slug.
*
* ⚠️ 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
* segmento `page` viene renderizzato in parallelo al layout, quindi un layout
* che non renderizza `{children}` nasconde la dashboard a schermo ma la sua
* query è già partita e il payload RSC finisce comunque nell'HTML. Verificato:
* 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);
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 session = await verifySessionValue(
@@ -39,5 +58,5 @@ export async function getClientGate(tokenOrSlug: string): Promise<GateResult> {
client.sessions_valid_from !== null &&
session.iat < client.sessions_valid_from.getTime();
return { client, session: revoked ? null : session };
return { client, session: revoked ? null : session, preview: false };
}