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
+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 };
}