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>
Il portale /client/<slug> era protetto dal solo token in URL: chiunque
ricevesse o intercettasse il link entrava, per sempre, senza identificarsi.
Ora l'admin registra le email autorizzate per cliente e il cliente si
identifica con un codice usa-e-getta prima di vedere qualsiasi dato.
- Resend 6.18.1 + src/lib/mailer.ts (Result tipizzato, mai catch silenzioso)
- migration 0015 (gia applicata a prod): client_emails, otp_codes,
clients.sessions_valid_from. Additiva pura, conteggi verificati pre/post
- admin: sezione "Accessi al portale" in /admin/clients/[id] con whitelist
e revoca sessioni in blocco
- gate: codice 6 cifre CSPRNG, hash SHA-256 (mai il codice in chiaro),
TTL 15 min, max 5 tentativi, rate limit su entrambi gli endpoint,
risposta identica per email in whitelist e non (no enumeration)
- sessione: cookie HMAC per-cliente, 90 giorni, httpOnly/secure/lax
Il gate sta in cima alla page, NON nel layout: nell'App Router il segmento
page viene renderizzato in parallelo al layout, quindi gattare nel layout
nascondeva la dashboard a schermo ma lasciava fasi, task e pagamenti nel
payload RSC dell'HTML (46907 byte -> 17594 dopo il fix). Verificato.
Verifica: build OK, 9/9 test E2E in locale contro il DB di produzione.
NON DEPLOYARE prima di: RESEND_API_KEY+RESEND_FROM su Coolify e whitelist
popolata per i 3 clienti reali (oggi vuota) - altrimenti il gate li chiude
fuori dal loro portale. Checklist in .planning/STATE.md.
SEND-01/02 (invio preventivo via email) rinviati a v2.4 su richiesta.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Pagamenti: totale ereditato dalla somma accepted_total delle offerte
attive (con override) + selettore schema rate 1/2/3 step; nuova colonna
payments.percent per rescalare gli importi preservando label/stato
- Fasi & Task: recomputePhaseStatus auto-cascade (task -> fase) su
updateTaskStatus/addTask, fix UI stale, etichette Da iniziare/In corso/Completata
- Pagina pubblica: colori fasi (grigio/blu/#1A463C) + fasi collassabili
- Transcript del cliente visibili in tab Documenti admin e dashboard pubblica
- Timer: inserimento manuale (+30/+60, minuti liberi, data) + elimina entry
- CLAUDE.md: procedura Deploy & DB Access; push su main automatico
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause: INTERNAL_SECRET was on same line as HOST= in .env (no trailing
newline when appended), so proxy middleware got empty secret and rejected
all /api/internal/validate-* calls → rewrote every /client/* request to
/not-found without running the page component.
- Admin client list: remove Timer column, rename Acconto→Importo incassato
and Saldo→Importo da saldare (calculated from payment amounts, not badges)
- Project workspace timer: pass projectId prop to TimerCell so it calls
startTimer(projectId) directly instead of startTimerForClient(projectId)
which was failing with "nessun progetto trovato"
- Public client page: pass client.token (real DB token) not the URL path
segment (which may be a slug) to ClientDashboard → fixes approve/chat APIs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>