Files
clienthub/.planning/STATE.md
T
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

178 lines
18 KiB
Markdown

---
gsd_state_version: 1.0
milestone: v2.3
milestone_name: Email & Accesso
status: executing
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
total_plans: 3
completed_plans: 3
percent: 100
---
# Project State
## Project Reference
See: .planning/PROJECT.md (updated 2026-06-21)
**Core value:** Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa deve ancora succedere e cosa ha già approvato — senza dover scrivere email per chiedere aggiornamenti.
**Current focus:** Milestone **v2.3 "Email & Accesso"****consegnata**. Il portale cliente è protetto da gate email OTP. L'invio del preventivo via email (SEND-01/02) è stato rinviato a v2.4 — si manda a mano.
## Current Position
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)
- **Dominio `iamcavalli.net` verificato su Resend.** L'utente ha ricreato la registrazione del dominio (nuovo id `f81202f1-3bba-47c5-8c0f-84101440b960`, la precedente `a2a80798-…` non esiste più) e messo i DNS. Tutti e tre i record `verified`: DKIM TXT su `resend._domainkey`, SPF TXT + MX su `send`. Invio da `no-reply@iamcavalli.net` verso un indirizzo esterno confermato riuscito.
- *Nota:* ricreare il dominio su Resend **rigenera la chiave DKIM**. Se in futuro il dominio torna `failed`, non fidarsi di valori DKIM annotati in passato — rileggerli da `GET /domains` e confrontarli con `dig +short TXT resend._domainkey.iamcavalli.net @8.8.8.8`.
- `RESEND_API_KEY` + `RESEND_FROM` **configurate su Coolify** (production e preview) via API — verificate presenti.
- **Mailer verificato**: `sendEmail()` col template OTP reale ha restituito `{ok:true, id:…}`.
- Verificato che quando Resend rifiuta, la route risponde comunque col messaggio neutro e logga l'errore lato server — il no-enumeration tiene anche a provider guasto.
### ✅ Verificato in produzione (2026-07-29, commit 27da969)
Su `hub.iamcavalli.net`: gate mostrato senza cookie e **zero dati di progetto nell'HTML** (12.487 byte); email fuori whitelist e in whitelist danno risposta identica e solo la seconda genera un OTP; codice sbagliato rifiutato, corretto accettato; cookie `ch_sess_<id>` con `Secure` + `HttpOnly` + `SameSite=lax` + `Max-Age=7776000` (90 giorni); rientro col cookie mostra la dashboard; sessione di un cliente sull'URL di un altro mostra il gate. Nessun errore d'invio nei log del container. Dati di test rimossi, tabelle protette invariate (4/5/11/10).
### Da fare
Popolare la whitelist dei 3 clienti reali da `/admin/clients/<id>` → "Accessi al portale", e reinviare loro il link. La migration aveva seedato solo `mario@test.it` (cliente di test "Rossi Inc"); Protocollo Estetico, Caruso Speaker e Teckell hanno whitelist vuota e finché lo è il loro portale non è accessibile.
### Cosa è stato consegnato in v2.3 (codice locale, buildato e testato)
- **Resend**: `resend@6.18.1`, `src/lib/mailer.ts` (Result tipizzato, mai catch silenzioso) + template OTP in italiano.
- **Schema**: migration `0015_otp_access.sql` **già applicata a prod**`client_emails` (whitelist, unique case-insensitive), `otp_codes` (hash del codice, mai il codice), `clients.sessions_valid_from` (revoca). Additiva pura: conteggi pre/post identici su clients 4 / projects 5 / payments 11 / phases 10.
- **Admin**: sezione "Accessi al portale" in `/admin/clients/[id]` — aggiungi/rimuovi email + "Revoca sessioni attive". Server actions in `clients/[id]/actions.ts`. Scritta a token semantici benché la pagina attorno sia ancora a palette vecchia.
- **Gate**: `src/lib/otp.ts` (codice 6 cifre CSPRNG, hash SHA-256 con `NEXTAUTH_SECRET`+clientId, TTL 15 min, max 5 tentativi), `src/lib/client-session.ts` (cookie HMAC per-cliente `ch_sess_<id>`, 90 giorni, httpOnly/secure/lax, path=/client), `src/lib/client-gate.ts`, route `/api/client/otp/request|verify`, componente `OtpGate`.
### ⚠️ Lezione: il gate NON va nel layout
Prima implementazione: gate in `client/[token]/layout.tsx` che rendeva `<OtpGate/>` al posto di `{children}`. **Non funziona come protezione.** Nell'App Router il segmento `page` viene renderizzato in parallelo al layout: la dashboard spariva a schermo ma fasi, task e pagamenti restavano leggibili nel payload RSC dell'HTML (46.907 byte → 17.594 dopo il fix). Il gate è ora in cima alla `page`, prima di ogni query, via `getClientGate()`. **Ogni nuova route sotto `/client/[token]/` deve fare lo stesso** — il layout porta un commento che lo ricorda.
### Lavoro recente precedente (in prod)
- **[2026-07-27/28] Audit di sicurezza**: 4 vulnerabilità chiuse e deployate (secondo gate admin, hardening slug, XSS, CSP/HSTS); slug clienti deboli ruotati a 12 char CSPRNG; `INTERNAL_SECRET` e `ADMIN_PASSWORD` configurati su Coolify. Finding #1 (password Postgres committata) declassato CRITICO→BASSO: verificata inattiva, già ruotata. Report in `.planning/SECURITY-*.md`.
- **[2026-07-28] Riorganizzazione cartella**: fasi di planning consolidate, script one-off archiviati in `cestino/`, `CLAUDE.md` arricchito.
- **Design system "Quiet Luxury"**: dashboard, liste (Clienti/Offerte/Catalogo/Preventivi/Progetti), Conversazioni, Impostazioni, Pipeline+Kanban, dettaglio Lead e portale cliente base sono a token e dual-theme. **Ancora a design vecchio** (funzionanti, solo estetica): `/admin/offers/[id]/edit`, `/admin/projects/[id]` (il cluster peggiore, ~140 occorrenze fra i suoi tab), `/admin/projects/new`, `/admin/clients/[id]`, `/admin/clients/[id]/edit`, `/admin/login`, badge in `/admin/preventivi/[id]`, chat portale cliente — più, non censiti prima: **tutto `/quote/[token]`** (~40 occorrenze, pagina rivolta al cliente) e `ui/dialog.tsx`, che propaga la palette vecchia a ogni modale.
- **Tassonomie**: gestione centralizzata categorie/tag in Impostazioni (`src/lib/taxonomy.ts`).
- **Lead → Cliente (A+B)**: `clients.email/phone` + `leads.archived` (migration 0011); `convertLeadToClient`.
### Fasi completate (v2.2, storico)
Phase 18 (cleanup), Phase 19 (Kanban CRM), Phase 20 (transcript KB), Phase 21 (AI agent), Phase 22 (deck pubblico) — consegnate e in prod 2026-06-20.
## Performance Metrics
**Velocity:**
- Total plans completed: 7 (v2.1) + 9 (v2.2) = 16 totali
- Average duration: —
- Total execution time: —
**By Phase:**
| Phase | Plans | Total | Avg/Plan |
|-------|-------|-------|----------|
| Phase 11 P01 | 25min | 2 tasks | 6 files |
| 11 | 4 | - | - |
| 14 | 3 | - | - |
**Recent Trend:**
- Last 5 plans: —
- Trend: —
*Updated after each plan completion*
| Phase 11 P02 | 12min | 2 tasks | 2 files |
| Phase 11 P03 | 9min | 2 tasks | 2 files |
| Phase 11 P04 | 12min | 2 tasks | 4 files |
## Accumulated Context
### Decisions
Decisions are logged in PROJECT.md Key Decisions table.
Recent decisions affecting current work:
- **[v2.3 2026-06-21] Resend come provider email unico** — PUB-03 (invio preventivo) e AUTH-OTP-01 (OTP gate) condividono la stessa integrazione Resend. Phase 23 configura SDK + env vars, Phase 25 li riusa.
- **[v2.3 2026-06-21] OTP gate è strato aggiuntivo, non rimpiazzo del token middleware** — proxy.ts e `/api/internal/validate-token` rimangono invariati. Il gate OTP interviene dopo la validazione del token, nel rendering della route `/client/[token]/*`.
- **[v2.3 2026-06-21] Migration Phase 24 è additiva pura** — `client_emails` e `otp_codes` sono nuove tabelle. Nessun drop/truncate. SQL a mano (drizzle-kit generate rotto). Applicare via SSH prima del codice dipendente.
- **[RESET 2026-06-19] Milestone v2.2 "Sales Loop"** sostituisce le fasi residue v2.1. Decisioni bloccate: (1) URL preventivo = `/preventivo/[slug]` pubblico; (2) tagliare Forecast + quote builder manuale + Phase 15, fondere `/admin/analytics` nella dashboard; (3) portale post-vendita resta core, non si tocca (Phase 13 congelata); (4) agente AI = "io scelgo l'offerta, l'AI personalizza" leggendo i transcript, provider Claude. Piano: `.claude/plans/glittery-sprouting-pudding.md`
- [SUPERSEDED dal reset] v2.1 roadmap: Offer Studio (Phases 11-15) sequenced before Proposal AI (Phases 16-17) — clean/fast data UX before the AI builder
- Phase 11 bundles catalog database-view UX (OFFER-07..10) with legacy consolidation (OFFER-13) since the new UX should be built on a single unified `services` table, not on top of legacy `service_catalog`/`offer_services`
- Phase 13 (Workspace — Servizi Attivi) is independent of Phases 11/12 — can execute in parallel order if useful, but numbered after for narrative flow
- Phase 15 (Dashboard Revenue Stats / DASH-11) is isolated and BLOCKED on user-provided mockup; no other phase depends on it — can be deferred/skipped without blocking Phase 16/17
- Phase 16/17 split: schema/automation (payment link field + auto-provisioning) first, then AI builder + public page redesign + email — keeps the AI-dependent work last
- [Phase 11]: Phase 11: hand-write Drizzle migration SQL (0006_add_tags_table.sql) following the project's established convention since drizzle-kit generate is non-functional (meta snapshots out of sync since migration 0001, pre-existing since Phase 8) — Avoids architectural snapshot-reconciliation work (Rule 4, out of scope) while matching exact precedent from migrations 0003-0005
- [Phase 11]: Phase 11 Plan 02: onConflictDoNothing() without explicit target compiles cleanly for tags table (single unique index tags_entity_name_unique) — used as written in plan, no fallback needed — Avoids unnecessary deviation; Drizzle's no-target ON CONFLICT DO NOTHING is correct given the single unique constraint from Plan 01
- [Phase 11]: Phase 11 Plan 03: removed the plan's prescribed value-sync useEffect (and a follow-up render-time ref-read attempt) from EditableCell — both violate this project's react-hooks lint rules (set-state-in-effect, refs-during-render / React Compiler). tempValue is now only (re)initialized in startEdit()/cancel(), and the toggle display branch reads `value` directly instead of `tempValue` — Rule 1 lint fix, no behavioral change to the 8 spec'd test behaviors
- [Phase 11]: Phase 11 Plan 04: left `createService`/`serviceSchema` in `src/app/admin/catalog/actions.ts` as unused dead code after deleting `ServiceForm.tsx` (its only consumer) — `actions.ts` was outside this plan's `files_modified` scope and `updateService` still depends on `serviceSchema`; logged to deferred-items.md for future cleanup
- [Phase 18-02]: fmtEur unified to number version (analytics/page.tsx variant); KPI card callers using DB string values wrapped with parseFloat() — cleaner than maintaining two named variants
- [Phase 18-02]: /admin/analytics route deleted; YearSelector now routes to /admin?year=Y — single admin entry point for statistics (CLEAN-03)
### Pending Todos
[From .planning/todos/pending/ — ideas captured during sessions]
None yet.
### Blockers/Concerns
- ~~Record DKIM / dominio Resend~~ — **risolto 2026-07-29**: dominio ricreato e `verified`, invio dal dominio reale confermato.
- ~~`RESEND_API_KEY` + `RESEND_FROM` su Coolify~~ — **fatto 2026-07-29**, production e preview.
- **Whitelist vuota per 3 clienti su 4** (non bloccante: l'utente li re-invita) — da popolare da `/admin/clients/<id>` → "Accessi al portale".
- **Coolify API**: credenziali in `~/.coolify.env` (`export COOLIFY_URL/COOLIFY_TOKEN`, va sorgentato con `set -a; . ~/.coolify.env`). App ClientHub uuid `xsksow44g4kcoo8wocsgkscc`. Il POST su `/api/v1/applications/<uuid>/envs` **non accetta** il campo `is_build_time` (422): mandare solo `key`, `value`, `is_preview`. I token Hetzner/Cloudflare nel file sono **vuoti** → il DNS non è modificabile via API.
- **Migrations (sempre valido)**: ogni fase con schema DEVE avere la migration applicata a prod PRIMA di pushare il codice dipendente. `drizzle-kit generate` rotto → SQL a mano. La 0015 è già applicata. Due strade: `cat migration.sql | ssh root@178.104.27.55 "docker exec -i xwkk0040w0kk0gsgcgog8owk psql -U clienthub -d clienthub -v ON_ERROR_STOP=1 --single-transaction"` (autoritativa, nessun tunnel), oppure tunnel `ssh -f -N -L 54321:localhost:54321 root@178.104.27.55` con `DATABASE_URL` riscritto a `127.0.0.1:54321` se serve puntarci il tooling locale.
- **`.env.local` punta al DB di PRODUZIONE** (178.104.27.55:54321, richiede il tunnel). Non esiste un DB di sviluppo separato: qualsiasi test in locale scrive su dati reali. Verificare sempre i conteggi delle tabelle protette prima e dopo.
- **Debito tecnico (non bloccante)**: tabelle legacy `service_catalog`/`offer_services`/`offer_micro_services` restano come deadweight; `createService`/`serviceSchema` dead code in `catalog/actions.ts`.
## Deferred Items
Items acknowledged and carried forward from previous milestone close:
| Category | Item | Status | Deferred At |
|----------|------|--------|-------------|
| v2.4 | PROP-03 — Stripe Payment Link su deck pubblico | Backlog | v2.3 kickoff |
| v2.4 | PROP-04 — Auto-provisioning cliente/progetto/fasi al "Vinto" | Backlog | v2.3 kickoff |
| v2+ | Phase 13 — Servizi attivi/ricorrenti post-vendita | Congelata | v2.1 kickoff |
| v2 | OFFER-14 — Sezioni analitiche stile Notion | Backlog | v2.1 kickoff |
| v2 | ARCH-01 — Split modulo "compartimento stagno" in deploy separato | Backlog (only if module grows) | v2.1 kickoff |
| v2.4 | SEND-01/02 — Invio link preventivo via email dall'admin | Backlog (mailer già pronto) | 2026-07-28 |
| Design | 11 pagine ancora a palette vecchia — vedi elenco in "Lavoro recente" | Backlog | 2026-07-28 |
## Session Continuity
Last session: 2026-07-29T21:20:00.000Z
Stopped at: v2.3 deployata in produzione. Dominio Resend verificato, Coolify configurato, gate OTP live su `hub.iamcavalli.net`.
Next: popolare la whitelist dei 3 clienti reali da `/admin/clients/<id>` → "Accessi al portale", e reinviare loro il link.
Resume file: .planning/STATE.md