diff --git a/.claude/rules/memory-discipline.md b/.claude/rules/memory-discipline.md new file mode 100644 index 0000000..7eab54c --- /dev/null +++ b/.claude/rules/memory-discipline.md @@ -0,0 +1,28 @@ +# Regola: la memoria di progetto si aggiorna, sempre + +`.planning/STATE.md` è la fonte di verità su **dove siamo**. È rimasto fermo dal 2026-06-21 al 2026-07-28 mentre venivano chiusi un audit di sicurezza, una riorganizzazione della cartella e mezzo design system: chi riapriva il progetto leggeva uno stato falso. Questa regola esiste per impedire che si ripeta. + +## Quando aggiornare + +Dopo **ogni** unità di lavoro conclusa — una fase, una migration applicata, un fix deployato, una decisione presa che cambia la rotta. Non a fine milestone: a fine cosa. + +## Cosa scrivere in `.planning/STATE.md` + +- **Frontmatter**: `last_updated` (ISO, data reale), `last_activity`, `status`, `progress`. +- **Current Position**: fase, stato, e soprattutto **se qualcosa blocca**. +- **Blocchi**: marcati `[BLOCCANTE]`, con *cosa* manca e *chi/cosa* lo sblocca. Un blocco non scritto è un blocco che si riscopre a caro prezzo. +- **Lezioni**: quando un approccio si rivela sbagliato, scrivere *perché* falliva, non solo cosa si è fatto al suo posto. Serve a non riprovarci fra due mesi. +- **Date assolute**, mai "ieri" o "la settimana scorsa". + +## Cosa scrivere nella memoria persistente + +`~/.claude/projects/-Users-simonecavalli-Vault-IAMCAVALLI/memory/` — un file per fatto, più la riga di indice in `MEMORY.md`. + +Ci va quello che **non si deduce dal repo**: decisioni e il loro perché, vincoli operativi, cose che sono state provate e non funzionano. Non ci va quello che il codice già dice: struttura, cronologia dei fix, contenuto di `CLAUDE.md`. + +Se un fatto in memoria diventa falso, **correggerlo o cancellarlo**. Una memoria sbagliata è peggio di una memoria assente. + +## Cosa NON fare + +- Non scrivere "completato" per lavoro che compila ma non è stato verificato. Distinguere sempre *scritto* / *testato* / *in produzione* — sono tre stati diversi e confonderli è il modo più veloce per deployare un disastro. +- Non lasciare `STATE.md` a raccontare la milestone precedente. diff --git a/.claude/settings.json b/.claude/settings.json index d247d32..8506990 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -17,6 +17,18 @@ "Read(//Users/simonecavalli/Downloads/**)" ] }, + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "git -C \"$CLAUDE_PROJECT_DIR\" diff --quiet HEAD -- src .planning 2>/dev/null || echo 'PROMEMORIA memory-discipline: ci sono modifiche non committate in src/ o .planning/. Prima di chiudere, aggiorna .planning/STATE.md (last_updated, Current Position, blocchi marcati [BLOCCANTE]) e la memoria persistente se e cambiata una decisione. Regola: .claude/rules/memory-discipline.md'" + } + ] + } + ] + }, "enabledPlugins": { "impeccable@impeccable": true }, diff --git a/.env.example b/.env.example index cee7f9e..363a8dc 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,8 @@ ADMIN_PASSWORD=use-a-strong-password-min-20-chars # Internal API secret — shared between proxy.ts and /api/internal/* routes # Generate with: openssl rand -base64 32 INTERNAL_SECRET=generate-with-openssl-rand-base64-32 + +# Resend — invio del codice OTP per l'accesso al portale cliente +# RESEND_FROM deve usare un dominio verificato su Resend +RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx +RESEND_FROM=Nome Mittente diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index f3c0582..0119316 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -7,22 +7,21 @@ ### Email OTP Gate (AUTH-OTP-01) -Portale cliente blindato da email OTP. Nuovo `client_emails` table (whitelist) + `otp_codes` table (codice, email, expires_at, consumed). Resend come provider email. Sessione 30gg con cookie dopo verifica. +Portale cliente blindato da email OTP. Nuovo `client_emails` table (whitelist) + `otp_codes` table (codice, email, expires_at, consumed). Resend come provider email. Sessione **90 giorni** con cookie dopo verifica, revocabile dall'admin. -- [ ] **OTP-01**: Admin può aggiungere e rimuovere email dalla whitelist di ogni cliente nell'admin UI -- [ ] **OTP-02**: Cliente senza sessione OTP vede una schermata "inserisci email" invece della dashboard -- [ ] **OTP-03**: Sistema invia OTP via Resend solo se l'email inserita è nella whitelist di quel cliente -- [ ] **OTP-04**: Cliente inserisce il codice OTP ricevuto e ottiene sessione autenticata (cookie 30 giorni) -- [ ] **OTP-05**: Codici OTP scadono dopo 15 minuti dall'invio -- [ ] **OTP-06**: Endpoint OTP è rate-limited per prevenire brute force -- [ ] **OTP-07**: Messaggi di errore OTP non rivelano se l'email è in whitelist o no (no enumeration) +- [x] **OTP-01**: Admin può aggiungere e rimuovere email dalla whitelist di ogni cliente nell'admin UI +- [x] **OTP-02**: Cliente senza sessione OTP vede una schermata "inserisci email" invece della dashboard +- [x] **OTP-03**: Sistema invia OTP via Resend solo se l'email inserita è nella whitelist di quel cliente +- [x] **OTP-04**: Cliente inserisce il codice OTP ricevuto e ottiene sessione autenticata (cookie **90 giorni**) +- [x] **OTP-05**: Codici OTP scadono dopo 15 minuti dall'invio +- [x] **OTP-06**: Endpoint OTP è rate-limited per prevenire brute force +- [x] **OTP-07**: Messaggi di errore OTP non rivelano se l'email è in whitelist o no (no enumeration) +- [x] **OTP-08**: Admin può revocare in blocco tutte le sessioni attive di un cliente -### Invio Link Preventivo via Email (PUB-03) - -Admin invia link deck pubblico al lead direttamente dall'admin UI. Usa stessa infrastruttura Resend del OTP gate. - -- [ ] **SEND-01**: Admin può inviare link `/preventivo/[slug]` via email al lead con un'azione dall'admin UI -- [ ] **SEND-02**: Email inviata via Resend include link deck + nome cliente, template minimale in italiano +> **[2026-07-28] Modifiche alla spec del 2026-06-21**, decise in sessione: +> - Sessione **90 giorni** invece di 30 (rientro più fluido), compensata da OTP-08. +> - **SEND-01/SEND-02 spostati al backlog v2.4**: il preventivo si invia a mano, l'automazione non serve ora. Phase 23 si è ridotta alla sola infrastruttura Resend, che l'OTP usa comunque. +> - **Il gate NON sta nel layout** ma in cima a ogni page sotto `/client/[token]/`. Nell'App Router il segmento `page` viene renderizzato in parallelo al layout: gattare nel layout nascondeva la dashboard a schermo ma lasciava fasi, task e pagamenti nel payload RSC dell'HTML (verificato: 46.907 byte con i dati → 17.594 dopo il fix). Helper: `src/lib/client-gate.ts`. ## v2.4+ Backlog @@ -30,6 +29,7 @@ Admin invia link deck pubblico al lead direttamente dall'admin UI. Usa stessa in - **PROP-03**: Stripe Payment Link su deck pubblico `/preventivo/[slug]` - **PROP-04**: Auto-provisioning cliente/progetto/fasi al "Vinto" nel CRM +- **SEND-01/SEND-02**: invio del link `/preventivo/[slug]` via email dall'admin UI — *rinviato da v2.3 il 2026-07-28, l'invio si fa a mano. L'infrastruttura Resend (`src/lib/mailer.ts`) è già pronta, manca solo l'azione e il pulsante.* ### Post-Vendita @@ -48,21 +48,22 @@ Admin invia link deck pubblico al lead direttamente dall'admin UI. Usa stessa in | Requirement | Phase | Status | |-------------|-------|--------| -| OTP-01 | Phase 24 | Pending | -| OTP-02 | Phase 25 | Pending | -| OTP-03 | Phase 25 | Pending | -| OTP-04 | Phase 25 | Pending | -| OTP-05 | Phase 25 | Pending | -| OTP-06 | Phase 25 | Pending | -| OTP-07 | Phase 25 | Pending | -| SEND-01 | Phase 23 | Pending | -| SEND-02 | Phase 23 | Pending | +| OTP-01 | Phase 24 | ✅ Done (2026-07-28) | +| OTP-02 | Phase 25 | ✅ Done (2026-07-28) | +| OTP-03 | Phase 25 | ✅ Done (2026-07-28) | +| OTP-04 | Phase 25 | ✅ Done (2026-07-28) | +| OTP-05 | Phase 25 | ✅ Done (2026-07-28) | +| OTP-06 | Phase 25 | ✅ Done (2026-07-28) | +| OTP-07 | Phase 25 | ✅ Done (2026-07-28) | +| OTP-08 | Phase 24 | ✅ Done (2026-07-28) | +| SEND-01 | — | ⏭️ Rinviato a v2.4 | +| SEND-02 | — | ⏭️ Rinviato a v2.4 | **Coverage:** -- v2.3 requirements: 9 total -- Mapped to phases: 9 -- Unmapped: 0 ✓ +- v2.3 requirements: 8 in scope (OTP-01…08) + 2 rinviati +- Implementati: 8/8 ✓ — verificati con 9 test E2E in locale contro il DB di produzione +- **Non ancora in produzione**: il codice è scritto e testato ma NON pushato. Vedi i blocchi in `STATE.md`. --- *Requirements defined: 2026-06-21* -*Last updated: 2026-06-21 — traceability filled after roadmap creation* +*Last updated: 2026-07-28 — sessione 90gg, OTP-08 aggiunto, SEND-01/02 rinviati, OTP-01…08 implementati* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 0b6a194..8e34aeb 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -36,9 +36,11 @@ Archivio completo: [milestones/v2.2-ROADMAP.md](milestones/v2.2-ROADMAP.md) ### 🔨 v2.3 — Email & Accesso (Phases 23–25) -- [ ] **Phase 23: Resend Setup + Invio Preventivo** — Integrazione Resend e invio link deck dall'admin -- [ ] **Phase 24: Schema + Whitelist Admin** — Tabelle `client_emails` e `otp_codes`, admin UI gestione whitelist -- [ ] **Phase 25: OTP Gate + Sessione** — Gate OTP completo, sessione 30gg, rate limiting, no enumeration +- [x] **Phase 23: Resend Setup** — SDK Resend + `src/lib/mailer.ts` + template OTP *(l'invio preventivo è stato rinviato a v2.4 il 2026-07-28)* +- [x] **Phase 24: Schema + Whitelist Admin** — Tabelle `client_emails` e `otp_codes`, admin UI gestione whitelist + revoca sessioni +- [x] **Phase 25: OTP Gate + Sessione** — Gate OTP completo, sessione **90gg**, rate limiting, no enumeration + +> ⚠️ **Codice completo e testato, NON ancora in produzione** (2026-07-28). Il deploy è bloccato da: `RESEND_API_KEY` assente su Coolify e whitelist vuota per 3 clienti su 4. Dettagli e checklist in `STATE.md`. ## Phase Details @@ -95,9 +97,9 @@ Archivio completo: [milestones/v2.2-ROADMAP.md](milestones/v2.2-ROADMAP.md) | 20. Knowledge Base Cliente | v2.2 | 3/3 | ✅ Done | 2026-06-20 | | 21. Agente AI Preventivo | v2.2 | 1/1 | ✅ Done | 2026-06-20 | | 22. Pagina Pubblica + Deck | v2.2 | 1/1 | ✅ Done | 2026-06-20 | -| 23. Resend Setup + Invio Preventivo | v2.3 | 0/? | Not started | — | -| 24. Schema + Whitelist Admin | v2.3 | 0/? | Not started | — | -| 25. OTP Gate + Sessione | v2.3 | 0/? | Not started | — | +| 23. Resend Setup | v2.3 | 1/1 | ✅ Done | 2026-07-28 | +| 24. Schema + Whitelist Admin | v2.3 | 1/1 | ✅ Done | 2026-07-28 | +| 25. OTP Gate + Sessione | v2.3 | 1/1 | ✅ Done (non deployato) | 2026-07-28 | --- diff --git a/.planning/STATE.md b/.planning/STATE.md index 3e110cb..4a64760 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,16 +2,16 @@ gsd_state_version: 1.0 milestone: v2.3 milestone_name: Email & Accesso -status: planning -stopped_at: "" -last_updated: "2026-06-22T09:00:00.000Z" -last_activity: 2026-06-22 -- Lead→Cliente (A+B) in prod; cleanup tab progetto + offerta→fasi in corso +status: executing +stopped_at: "v2.3 implementata e testata in locale; deploy BLOCCATO su RESEND_API_KEY + whitelist clienti" +last_updated: "2026-07-28T20:45:00.000Z" +last_activity: 2026-07-28 -- Phases 23/24/25 (OTP gate) implementate e verificate E2E; non ancora pushate progress: total_phases: 3 - completed_phases: 0 - total_plans: 0 - completed_plans: 0 - percent: 0 + completed_phases: 3 + total_plans: 3 + completed_plans: 3 + percent: 100 --- # Project State @@ -22,20 +22,41 @@ 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"** (started 2026-06-21). North-star: OTP gate per il portale cliente + invio link preventivo via email — un'unica integrazione Resend condivisa. Roadmap: `.planning/ROADMAP.md` (Phases 23–25). Prossimo passo: `/gsd-plan-phase 23`. +**Current focus:** Milestone **v2.3 "Email & Accesso"**. OTP gate per il portale cliente: **implementato e testato, in attesa di deploy**. L'invio del preventivo via email (SEND-01/02) è stato rinviato a v2.4 — si manda a mano. ## Current Position -Phase: Pre-23 fixes & flow wiring (fuori roadmap formale) -Plan: `.claude/plans/te-li-scrivo-tutti-lucky-hearth.md` -Status: In esecuzione — cleanup tab progetto + collegamento offerta→fasi/task -Last activity: 2026-06-22 — Lead→Cliente consegnato in prod +Phase: 23/24/25 completate — v2.3 pronta ma NON in produzione +Plan: `~/.claude/plans/si-ma-abbiamo-un-jaunty-micali.md` +Status: **Bloccata sul deploy**, non sul codice. Vedi "Blocchi al deploy" qui sotto. +Last activity: 2026-07-28 — gate OTP completo, 9/9 test E2E passati contro il DB di produzione -### Lavoro recente (pre-fase-23, in prod) +### ⛔ Blocchi al deploy (2026-07-28) -- **Tassonomie**: gestione centralizzata categorie/tag in Impostazioni (modello Notion, pool persistenti, `src/lib/taxonomy.ts`). -- **Lead → Cliente (A+B)**: campi `clients.email/phone` + `leads.archived` (migrazione 0011 applicata a prod); `convertLeadToClient` (riusa `createClientCore`, porta i transcript, archivia il lead mantenendo "won"); tasto Converti/Convertito; lead archiviati nascosti da lista/kanban. -- **In corso**: rimozione tab Preventivo dal progetto, riordino sidebar, tab Offerte rifatta, import offerta→fasi/task per `services.fase`. +Pushare così com'è **chiude fuori i clienti reali dal loro portale**. Prima del push servono, in quest'ordine: + +1. **`RESEND_API_KEY` + `RESEND_FROM` su Coolify** (production). Senza, nessuno riceve il codice e il portale è inaccessibile a tutti. Serve un account Resend con dominio verificato. In `.env.local` i due nomi ci sono ma il valore della key è vuoto. +2. **Whitelist popolata per i 3 clienti reali.** La migration ha seedato `client_emails` da `clients.email`, ma solo 1 cliente su 4 aveva quel campo valorizzato (`mario@test.it`, cliente di test "Rossi Inc"). George Vlad / Protocollo Estetico, Gian Luca Caruso / Caruso Speaker e Gianfranco Barban / Teckell hanno **whitelist vuota** → gate senza via d'uscita. Da fare da `/admin/clients/` → sezione "Accessi al portale". +3. Solo dopo: push su `main` → auto-deploy Coolify → riverifica su `hub.iamcavalli.net`. + +### 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_`, 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 `` 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) @@ -98,8 +119,10 @@ None yet. ### Blockers/Concerns -- **Migrations (sempre valido)**: ogni fase con schema (Phase 24: `client_emails` + `otp_codes`) DEVE avere la migration applicata a prod via tunnel SSH (`ssh -L 54321:localhost:54321 root@178.104.27.55`, `DATABASE_URL` riscritto a `127.0.0.1:54321`) PRIMA di pushare il codice dipendente. `drizzle-kit generate` rotto → SQL a mano. -- **Resend env vars**: `RESEND_API_KEY` e `RESEND_FROM` devono essere aggiunti a Coolify prima di testare Phase 23 in prod. Stessa procedura di `ANTHROPIC_API_KEY` (2026-06-20). +- **[BLOCCANTE] `RESEND_API_KEY` + `RESEND_FROM` su Coolify** — senza, il gate OTP deployato rende il portale inaccessibile a tutti i clienti. Stessa procedura di `ANTHROPIC_API_KEY` (2026-06-20). Serve un account Resend con dominio verificato. +- **[BLOCCANTE] Whitelist vuota per 3 clienti su 4** — vedi "Blocchi al deploy". Da popolare da `/admin/clients/` prima del push. +- **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 @@ -113,9 +136,12 @@ Items acknowledged and carried forward from previous milestone close: | 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-06-21T11:05:00.000Z -Stopped at: Roadmap v2.3 created — Phases 23–25, 9/9 requirements mapped. Next: `/gsd-plan-phase 23` -Resume file: .planning/ROADMAP.md +Last session: 2026-07-28T20:45:00.000Z +Stopped at: v2.3 implementata e verificata E2E in locale (9/9). **Non pushata**: mancano `RESEND_API_KEY` su Coolify e la whitelist dei 3 clienti reali. +Next: configurare Resend → popolare le whitelist → push su `main` → riverifica su `hub.iamcavalli.net`. +Resume file: .planning/STATE.md diff --git a/CLAUDE.md b/CLAUDE.md index 41bb739..aa7bb91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,9 @@ Other docs: `STATUS.md` (current project status + backlog) · `.planning/STATE.m ## GSD Workflow Planning in `.planning/`. Use `/gsd-plan-phase N` → `/gsd-execute-phase N`. State in `.planning/STATE.md`. +## Memory Discipline +@.claude/rules/memory-discipline.md + ## Data Safety (LOCKED) - Any migration, refactor, or deploy MUST NOT delete or truncate `clients`, `projects`, `payments`, or `phases` rows - Before running any migration: verify it only adds columns/tables — never drops or truncates production data diff --git a/package-lock.json b/package-lock.json index 58efadb..fad213a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-hook-form": "^7.75.0", + "resend": "^6.18.1", "tailwind-merge": "^3.6.0", "tailwindcss-animate": "^1.0.7", "zod": "^4.4.3" @@ -8394,6 +8395,12 @@ "node": ">= 0.4" } }, + "node_modules/postal-mime": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.5.tgz", + "integrity": "sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", @@ -8693,6 +8700,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resend": { + "version": "6.18.1", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.18.1.tgz", + "integrity": "sha512-XN8XIaDdKF+ziSQ3K23ndUcyhP7U3ze2gky6SPgYkuAOq54mH4Wdhwm7QylEQ3zlz0NzdX7/l1AgmJUZbdPI/Q==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.5", + "standardwebhooks": "1.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "2.0.0-next.6", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", diff --git a/package.json b/package.json index 625ecb0..43fbdc1 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "react-hook-form": "^7.75.0", + "resend": "^6.18.1", "tailwind-merge": "^3.6.0", "tailwindcss-animate": "^1.0.7", "zod": "^4.4.3" diff --git a/src/app/admin/clients/[id]/actions.ts b/src/app/admin/clients/[id]/actions.ts index 24bdece..413abe3 100644 --- a/src/app/admin/clients/[id]/actions.ts +++ b/src/app/admin/clients/[id]/actions.ts @@ -19,8 +19,10 @@ import { clients, projects, comments, + client_emails, + otp_codes, } from "@/db/schema"; -import { eq, asc, inArray } from "drizzle-orm"; +import { eq, asc, and, isNull } from "drizzle-orm"; import { z } from "zod"; // ── ENTITY RESOLUTION ──────────────────────────────────────────────────────── @@ -380,4 +382,85 @@ export async function postAdminComment(id: string, formData: FormData) { await db.insert(comments).values({ entity_type, entity_id, author: "admin", body }); const { path } = await resolveEntity(id); revalidatePath(path); +} + +// ── ACCESSI PORTALE (whitelist OTP) ────────────────────────────────────────── +// La whitelist è l'unico modo per entrare nel portale: nessuna auto-registrazione. +// Solo l'admin scrive qui — il gate OTP in /client/[token] si limita a leggerla. + +const clientEmailSchema = z.object({ + email: z + .string() + .trim() + .toLowerCase() + .min(1, "Email richiesta") + .email("Email non valida"), +}); + +export type AccessActionResult = { ok: true } | { ok: false; error: string }; + +export async function addClientEmail( + clientId: string, + formData: FormData +): Promise { + await requireAdmin(); + + const parsed = clientEmailSchema.safeParse({ email: formData.get("email") }); + if (!parsed.success) { + return { ok: false, error: parsed.error.issues[0].message }; + } + + const exists = await db + .select({ id: clients.id }) + .from(clients) + .where(eq(clients.id, clientId)) + .limit(1); + if (!exists[0]) return { ok: false, error: "Cliente non trovato" }; + + try { + await db.insert(client_emails).values({ client_id: clientId, email: parsed.data.email }); + } catch { + // Unique index su (client_id, lower(email)): il duplicato non è un errore + // per l'admin, l'email è già autorizzata. Nessun altro vincolo può fallire qui. + return { ok: false, error: "Questa email è già autorizzata" }; + } + + revalidatePath(`/admin/clients/${clientId}`); + return { ok: true }; +} + +export async function removeClientEmail( + clientId: string, + emailId: string +): Promise { + await requireAdmin(); + + // client_id nella WHERE: impedisce di cancellare una riga di un altro cliente + // passando un emailId arbitrario. + await db + .delete(client_emails) + .where(and(eq(client_emails.id, emailId), eq(client_emails.client_id, clientId))); + + revalidatePath(`/admin/clients/${clientId}`); + return { ok: true }; +} + +/** + * Revoca tutte le sessioni portale già emesse per il cliente. + * Non cancella nulla: alza sessions_valid_from a "adesso", e ogni cookie firmato + * prima di questo istante smette di validare. I codici OTP pendenti vengono + * invalidati insieme, così un codice già inviato non riapre l'accesso. + */ +export async function revokeClientSessions(clientId: string): Promise { + await requireAdmin(); + + const now = new Date(); + await db.update(clients).set({ sessions_valid_from: now }).where(eq(clients.id, clientId)); + await db + .update(otp_codes) + .set({ consumed_at: now }) + .where(and(eq(otp_codes.client_id, clientId), isNull(otp_codes.consumed_at))); + + revalidatePath(`/admin/clients/${clientId}`); + return { ok: true }; } \ No newline at end of file diff --git a/src/app/admin/clients/[id]/page.tsx b/src/app/admin/clients/[id]/page.tsx index b6a697e..8367268 100644 --- a/src/app/admin/clients/[id]/page.tsx +++ b/src/app/admin/clients/[id]/page.tsx @@ -1,6 +1,11 @@ import { notFound } from "next/navigation"; -import { getClientWithProjects, getClientActiveOffers } from "@/lib/admin-queries"; +import { + getClientWithProjects, + getClientActiveOffers, + getClientEmails, +} from "@/lib/admin-queries"; import { ClientActions } from "@/components/admin/ClientActions"; +import { ClientAccessSection } from "@/components/admin/ClientAccessSection"; import Link from "next/link"; export const revalidate = 0; @@ -11,9 +16,10 @@ export default async function ClientDetailPage({ params: Promise<{ id: string }>; }) { const { id } = await params; - const [data, activeOffers] = await Promise.all([ + const [data, activeOffers, accessEmails] = await Promise.all([ getClientWithProjects(id), getClientActiveOffers(id), + getClientEmails(id), ]); if (!data) notFound(); @@ -167,6 +173,8 @@ export default async function ClientDetailPage({ )} + + ); } \ No newline at end of file diff --git a/src/app/api/client/otp/request/route.ts b/src/app/api/client/otp/request/route.ts new file mode 100644 index 0000000..0d58fad --- /dev/null +++ b/src/app/api/client/otp/request/route.ts @@ -0,0 +1,65 @@ +// Richiesta del codice OTP per accedere al portale cliente. +// +// OTP-07 (no enumeration): la risposta è IDENTICA che l'email sia in whitelist +// o no — stesso status, stesso corpo, nessuna differenza osservabile. Chi ha il +// link non deve poter scoprire quali indirizzi sono autorizzati. + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { rateLimit } from "@/lib/rate-limit"; +import { getClientIdentityByToken } from "@/lib/client-view"; +import { isEmailWhitelisted, issueOtp } from "@/lib/otp"; +import { otpEmailTemplate, sendEmail } from "@/lib/mailer"; + +const schema = z.object({ + token: z.string().min(1), + email: z.string().trim().toLowerCase().email(), +}); + +// Sempre questa, in ogni esito non-tecnico. +const NEUTRAL = { + message: "Se l'indirizzo è autorizzato, riceverai un codice tra pochi istanti.", +}; + +export async function POST(request: NextRequest) { + const ip = request.headers.get("x-forwarded-for") ?? request.headers.get("x-real-ip") ?? "unknown"; + + try { + const parsed = schema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json({ error: "Indirizzo email non valido" }, { status: 400 }); + } + const { token, email } = parsed.data; + + // Bucket per IP+cliente: 3 invii ogni 10 minuti. Impedisce di usare + // l'endpoint come mail-bomber e di sondare la whitelist a raffica. + if (!rateLimit(`otp-req:${ip}:${token}`, 3, 10 * 60_000)) { + return NextResponse.json( + { error: "Troppe richieste. Riprova tra qualche minuto." }, + { status: 429 } + ); + } + + const client = await getClientIdentityByToken(token); + // Anche il cliente inesistente riceve la risposta neutra: il proxy ha già + // filtrato i link non validi, qui non si aggiunge un secondo oracolo. + if (!client) return NextResponse.json(NEUTRAL, { status: 200 }); + + if (!(await isEmailWhitelisted(client.id, email))) { + return NextResponse.json(NEUTRAL, { status: 200 }); + } + + const code = await issueOtp(client.id, email); + const { subject, html } = otpEmailTemplate(code, client.brand_name); + const sent = await sendEmail({ to: email, subject, html }); + + // Il fallimento di Resend si logga ma non si racconta al client, altrimenti + // "errore di invio" vs risposta neutra distinguerebbe le email in whitelist. + if (!sent.ok) console.error("[otp/request] invio fallito:", sent.error); + + return NextResponse.json(NEUTRAL, { status: 200 }); + } catch (err) { + console.error("/api/client/otp/request error:", err); + return NextResponse.json({ error: "Errore interno" }, { status: 500 }); + } +} diff --git a/src/app/api/client/otp/verify/route.ts b/src/app/api/client/otp/verify/route.ts new file mode 100644 index 0000000..83c9e3c --- /dev/null +++ b/src/app/api/client/otp/verify/route.ts @@ -0,0 +1,67 @@ +// Verifica del codice OTP: se corretto, emette il cookie di sessione (90 giorni). + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { rateLimit } from "@/lib/rate-limit"; +import { getClientIdentityByToken } from "@/lib/client-view"; +import { verifyOtp } from "@/lib/otp"; +import { + SESSION_MAX_AGE_SECONDS, + createSessionValue, + sessionCookieName, +} from "@/lib/client-session"; + +const schema = z.object({ + token: z.string().min(1), + email: z.string().trim().toLowerCase().email(), + code: z.string().trim().regex(/^\d{6}$/, "Il codice ha 6 cifre"), +}); + +// Codice sbagliato, scaduto o inesistente danno lo stesso messaggio: dire +// "scaduto" a un codice mai emesso confermerebbe che l'email è in whitelist. +const INVALID = "Codice non valido o scaduto. Richiedine uno nuovo."; + +export async function POST(request: NextRequest) { + const ip = request.headers.get("x-forwarded-for") ?? request.headers.get("x-real-ip") ?? "unknown"; + + try { + const parsed = schema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json({ error: INVALID }, { status: 400 }); + } + const { token, email, code } = parsed.data; + + // 5 tentativi ogni 15 minuti per IP+cliente: con 1M di codici possibili e + // TTL 15 min, indovinare per forza bruta è fuori portata. + if (!rateLimit(`otp-vrf:${ip}:${token}`, 5, 15 * 60_000)) { + return NextResponse.json( + { error: "Troppi tentativi. Riprova tra qualche minuto." }, + { status: 429 } + ); + } + + const client = await getClientIdentityByToken(token); + if (!client) return NextResponse.json({ error: INVALID }, { status: 400 }); + + const result = await verifyOtp(client.id, email, code); + if (!result.ok) { + return NextResponse.json({ error: INVALID }, { status: 400 }); + } + + const response = NextResponse.json({ success: true }, { status: 200 }); + response.cookies.set({ + name: sessionCookieName(client.id), + value: await createSessionValue(client.id, email), + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/client", + maxAge: SESSION_MAX_AGE_SECONDS, + }); + + return response; + } catch (err) { + console.error("/api/client/otp/verify error:", err); + return NextResponse.json({ error: "Errore interno" }, { status: 500 }); + } +} diff --git a/src/app/client/[token]/layout.tsx b/src/app/client/[token]/layout.tsx index 5a10360..ccc25a4 100644 --- a/src/app/client/[token]/layout.tsx +++ b/src/app/client/[token]/layout.tsx @@ -5,10 +5,23 @@ export const metadata: Metadata = { description: 'Project status dashboard', }; +/** + * ⚠️ Il gate OTP NON sta qui, e non per dimenticanza. + * + * Nell'App Router il segmento `page` viene renderizzato in parallelo al layout: + * un layout che restituisce il form di accesso al posto di `{children}` nasconde + * la dashboard a schermo, ma la page ha già interrogato il DB e i suoi dati + * finiscono comunque nel payload RSC dell'HTML. Testato — fasi, task e pagamenti + * erano leggibili nel sorgente della pagina di accesso. + * + * Il gate è quindi in cima a ogni page sotto /client/[token]/ via + * getClientGate() (src/lib/client-gate.ts). Ogni NUOVA route qui sotto deve + * fare lo stesso, prima di qualsiasi query. + */ export default function ClientLayout({ children, }: { children: React.ReactNode; }) { return <>{children}; -} \ No newline at end of file +} diff --git a/src/app/client/[token]/page.tsx b/src/app/client/[token]/page.tsx index d6d10a2..93e73bd 100644 --- a/src/app/client/[token]/page.tsx +++ b/src/app/client/[token]/page.tsx @@ -7,7 +7,9 @@ import { type ClientView, type ClientProjectSummary, } from "@/lib/client-view"; +import { getClientGate } from "@/lib/client-gate"; import { ClientDashboard } from "@/components/client-dashboard"; +import { OtpGate } from "@/components/client/OtpGate"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { Comment } from "@/db/schema"; @@ -99,6 +101,14 @@ export default async function ClientPage({ }) { const { token } = await params; + // ⚠️ 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) { + return ; + } + const clientData = await getCachedClientData(token); if (!clientData) notFound(); diff --git a/src/components/admin/ClientAccessSection.tsx b/src/components/admin/ClientAccessSection.tsx new file mode 100644 index 0000000..6e7a919 --- /dev/null +++ b/src/components/admin/ClientAccessSection.tsx @@ -0,0 +1,148 @@ +"use client"; + +// Gestione della whitelist email che apre il portale di un cliente (gate OTP v2.3). +// Scritto a token semantici anche se la pagina che lo ospita è ancora a palette +// vecchia: quando /admin/clients/[id] verrà rifatta, questa sezione non si tocca. + +import { useRef, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { + addClientEmail, + removeClientEmail, + revokeClientSessions, +} from "@/app/admin/clients/[id]/actions"; +import type { ClientAccessEmail } from "@/lib/admin-queries"; +import { Button } from "@/components/ui/button"; + +export function ClientAccessSection({ + clientId, + emails, +}: { + clientId: string; + emails: ClientAccessEmail[]; +}) { + const [error, setError] = useState(null); + const [revokeArmed, setRevokeArmed] = useState(false); + const [revokedAt, setRevokedAt] = useState(null); + const [isPending, startTransition] = useTransition(); + const formRef = useRef(null); + const router = useRouter(); + + function handleAdd(formData: FormData) { + setError(null); + startTransition(async () => { + const res = await addClientEmail(clientId, formData); + if (!res.ok) { + setError(res.error); + return; + } + formRef.current?.reset(); + router.refresh(); + }); + } + + function handleRemove(emailId: string) { + startTransition(async () => { + await removeClientEmail(clientId, emailId); + router.refresh(); + }); + } + + function handleRevoke() { + if (!revokeArmed) { + setRevokeArmed(true); + return; + } + startTransition(async () => { + await revokeClientSessions(clientId); + setRevokeArmed(false); + setRevokedAt(new Date().toLocaleString("it-IT")); + router.refresh(); + }); + } + + return ( +
+
+
+

+ Accessi al portale +

+

+ Solo queste email possono richiedere il codice di accesso. Chi ha il link ma + non è in elenco non entra. +

+
+ + {revokeArmed ? ( +
+ + Tutti dovranno rifare l'accesso. Confermi? + + + +
+ ) : ( + + )} +
+ + {revokedAt && ( +

+ Sessioni revocate il {revokedAt}. +

+ )} + +
+ {emails.length === 0 ? ( +

+ Nessuna email autorizzata — il cliente non può ancora accedere al portale. +

+ ) : ( +
    + {emails.map((e) => ( +
  • + + {e.email} + + +
  • + ))} +
+ )} + +
+ + +
+
+ + {error &&

{error}

} +
+ ); +} diff --git a/src/components/client/OtpGate.tsx b/src/components/client/OtpGate.tsx new file mode 100644 index 0000000..c549551 --- /dev/null +++ b/src/components/client/OtpGate.tsx @@ -0,0 +1,154 @@ +"use client"; + +// Schermata di accesso al portale cliente: email → codice a 6 cifre. +// Design system a token, dual light/dark. Non usa ui/dialog.tsx, che è ancora +// a palette raw e romperebbe il tema scuro. + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; + +type Step = "email" | "code"; + +export function OtpGate({ token, brandName }: { token: string; brandName: string }) { + const [step, setStep] = useState("email"); + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [notice, setNotice] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + async function requestCode(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(null); + + try { + const res = await fetch("/api/client/otp/request", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, email }), + }); + const data = await res.json(); + + if (!res.ok) { + setError(data.error ?? "Errore. Riprova."); + return; + } + // La risposta è neutra per costruzione: si passa allo step successivo + // anche se l'email non è autorizzata, altrimenti l'UI rivelerebbe la + // whitelist che l'API si è preoccupata di non rivelare. + setNotice(data.message); + setStep("code"); + } catch { + setError("Connessione non riuscita. Riprova."); + } finally { + setLoading(false); + } + } + + async function submitCode(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(null); + + try { + const res = await fetch("/api/client/otp/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, email, code }), + }); + const data = await res.json(); + + if (!res.ok) { + setError(data.error ?? "Codice non valido."); + return; + } + // Il cookie è già impostato dalla risposta: basta ricaricare e il layout + // lascerà passare. + router.refresh(); + } catch { + setError("Connessione non riuscita. Riprova."); + } finally { + setLoading(false); + } + } + + return ( +
+
+

{brandName}

+

+ Accedi al tuo portale +

+ + {step === "email" ? ( + <> +

+ Inserisci l'indirizzo email concordato: ti inviamo un codice per entrare. +

+ +
+ setEmail(ev.target.value)} + required + autoFocus + autoComplete="email" + placeholder="nome@azienda.it" + className="w-full rounded-lg border border-border bg-card px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-1 focus:ring-ring" + /> + +
+ + ) : ( + <> +

{notice}

+ +
+ setCode(ev.target.value.replace(/\D/g, ""))} + required + autoFocus + autoComplete="one-time-code" + placeholder="000000" + className="w-full rounded-lg border border-border bg-card px-3 py-2.5 text-center font-mono text-lg tracking-[0.4em] text-foreground placeholder:tracking-[0.4em] placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-1 focus:ring-ring" + /> + +
+ + + + )} + + {error &&

{error}

} + +

+ Il codice scade dopo 15 minuti. Una volta entrato resti collegato per 90 giorni su + questo dispositivo. +

+
+
+ ); +} diff --git a/src/db/migrations/0015_otp_access.sql b/src/db/migrations/0015_otp_access.sql new file mode 100644 index 0000000..a9a5944 --- /dev/null +++ b/src/db/migrations/0015_otp_access.sql @@ -0,0 +1,62 @@ +-- Additive: email OTP access gate for the client portal (milestone v2.3). +-- +-- Il portale /client/ era protetto dal solo token in URL: chi ha il link +-- entra. Da qui l'admin registra le email autorizzate per cliente (whitelist, +-- niente auto-registrazione) e il cliente si identifica con un codice usa-e-getta +-- prima di vedere la dashboard. +-- +-- client_emails → whitelist 1-a-molti (un cliente può avere più soci) +-- otp_codes → codici emessi, hashati; il codice in chiaro non è mai persistito +-- clients.sessions_valid_from → revoca: alzarla invalida in blocco le sessioni +-- già emesse per quel cliente +-- +-- Nessun DROP, nessun TRUNCATE, nessuna colonna rimossa. Le tabelle protette +-- (clients, projects, payments, phases) sono toccate solo in ADD COLUMN. +-- Applicare a prod via SSH+docker exec PRIMA di pushare il codice dipendente. +-- Idempotente: safe to re-run. + +-- ── Whitelist email per cliente ────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS client_emails ( + id text PRIMARY KEY, + client_id text NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + email text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Unicità case-insensitive: mario@x.it e Mario@X.it sono la stessa persona. +CREATE UNIQUE INDEX IF NOT EXISTS client_emails_client_email_idx + ON client_emails (client_id, lower(email)); + +-- ── Codici OTP ─────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS otp_codes ( + id text PRIMARY KEY, + client_id text NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + email text NOT NULL, + code_hash text NOT NULL, + expires_at timestamptz NOT NULL, + consumed_at timestamptz, + attempts integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Lookup del codice più recente per (cliente, email) in fase di verifica. +CREATE INDEX IF NOT EXISTS otp_codes_client_email_idx + ON otp_codes (client_id, lower(email), created_at DESC); + +-- ── Revoca sessioni ────────────────────────────────────────────────────────── +-- NULL = nessuna revoca mai effettuata; ogni sessione firmata è valida. +ALTER TABLE clients ADD COLUMN IF NOT EXISTS sessions_valid_from timestamptz; + +-- ── Seed whitelist dai contatti già presenti ──────────────────────────────── +-- clients.email arriva dalla conversione lead→cliente (migration 0011). Chi ce +-- l'ha parte con la whitelist già popolata e non resta fuori dal proprio portale. +INSERT INTO client_emails (id, client_id, email) +SELECT + substr(md5(random()::text || c.id), 1, 21), + c.id, + btrim(c.email) +FROM clients c +WHERE c.email IS NOT NULL + AND btrim(c.email) <> '' + AND btrim(c.email) LIKE '%@%' +ON CONFLICT DO NOTHING; diff --git a/src/db/schema.ts b/src/db/schema.ts index c61b7e3..bfdc943 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -40,11 +40,60 @@ export const clients = pgTable("clients", { // Conversazioni inbox: timestamp of the admin's last read of this client's // conversation. NULL = never read (treated as unread). Set via markConversationRead. admin_last_read_at: timestamp("admin_last_read_at", { withTimezone: true }), + // OTP gate (v2.3): revoca in blocco delle sessioni portale già emesse. + // Una sessione è valida solo se firmata DOPO questo istante. NULL = mai revocate. + sessions_valid_from: timestamp("sessions_valid_from", { withTimezone: true }), created_at: timestamp("created_at", { withTimezone: true }) .notNull() .defaultNow(), }); +// ============ CLIENT ACCESS (OTP) ============ +// Whitelist admin-gestita: nessuna auto-registrazione. Un cliente può avere più +// email (i soci del progetto accedono allo stesso portale). +export const client_emails = pgTable( + "client_emails", + { + id: text("id") + .primaryKey() + .$defaultFn(() => nanoid()), + client_id: text("client_id") + .notNull() + .references(() => clients.id, { onDelete: "cascade" }), + email: text("email").notNull(), + created_at: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + // L'indice reale è su (client_id, lower(email)) — vedi 0015_otp_access.sql. + // Drizzle non modella le expression index: qui serve solo a documentarlo. + uniqueIndex("client_emails_client_email_idx").on(table.client_id, table.email), + ] +); + +// Codici OTP emessi. Si persiste solo l'hash: il codice in chiaro vive nell'email. +export const otp_codes = pgTable( + "otp_codes", + { + id: text("id") + .primaryKey() + .$defaultFn(() => nanoid()), + client_id: text("client_id") + .notNull() + .references(() => clients.id, { onDelete: "cascade" }), + email: text("email").notNull(), + code_hash: text("code_hash").notNull(), + expires_at: timestamp("expires_at", { withTimezone: true }).notNull(), + consumed_at: timestamp("consumed_at", { withTimezone: true }), + attempts: integer("attempts").notNull().default(0), + created_at: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [index("otp_codes_client_email_idx").on(table.client_id, table.email)] +); + // ============ PROJECTS ============ export const projects = pgTable("projects", { id: text("id") @@ -799,4 +848,8 @@ export type NewReminder = typeof reminders.$inferInsert; export type ClientTranscript = typeof clientTranscripts.$inferSelect; export type NewClientTranscript = typeof clientTranscripts.$inferInsert; export type Proposal = typeof proposals.$inferSelect; -export type NewProposal = typeof proposals.$inferInsert; \ No newline at end of file +export type NewProposal = typeof proposals.$inferInsert; +export type ClientEmail = typeof client_emails.$inferSelect; +export type NewClientEmail = typeof client_emails.$inferInsert; +export type OtpCode = typeof otp_codes.$inferSelect; +export type NewOtpCode = typeof otp_codes.$inferInsert; \ No newline at end of file diff --git a/src/lib/admin-queries.ts b/src/lib/admin-queries.ts index 6090bf9..9e5997b 100644 --- a/src/lib/admin-queries.ts +++ b/src/lib/admin-queries.ts @@ -23,6 +23,7 @@ import { leads, tags, clientTranscripts, + client_emails, } from "@/db/schema"; import { eq, inArray, asc, desc, isNull, sql, and } from "drizzle-orm"; import { getPool } from "@/lib/taxonomy"; @@ -1098,6 +1099,23 @@ export async function getClientIdFromLead(leadId: string): Promise { + return db + .select({ + id: client_emails.id, + email: client_emails.email, + created_at: client_emails.created_at, + }) + .from(client_emails) + .where(eq(client_emails.client_id, clientId)) + .orderBy(asc(client_emails.created_at)); +} + export type LeadFieldOptions = { status: string[]; tags: string[] }; export async function getLeadFieldOptions(): Promise { diff --git a/src/lib/client-gate.ts b/src/lib/client-gate.ts new file mode 100644 index 0000000..45665da --- /dev/null +++ b/src/lib/client-gate.ts @@ -0,0 +1,43 @@ +import { cache } from "react"; +import { cookies } from "next/headers"; +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 }; + +// cache(): layout e page risolvono lo stesso cliente nella stessa richiesta +// senza fare due giri di query. +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. + * + * 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. + */ +export async function getClientGate(tokenOrSlug: string): Promise { + const client = await resolveClient(tokenOrSlug); + if (!client) return { client: null, session: null }; + + const cookieStore = await cookies(); + const session = await verifySessionValue( + cookieStore.get(sessionCookieName(client.id))?.value, + client.id + ); + + // Revoca admin: una sessione firmata prima di sessions_valid_from non vale più. + const revoked = + session !== null && + client.sessions_valid_from !== null && + session.iat < client.sessions_valid_from.getTime(); + + return { client, session: revoked ? null : session }; +} diff --git a/src/lib/client-session.ts b/src/lib/client-session.ts new file mode 100644 index 0000000..8f0eac4 --- /dev/null +++ b/src/lib/client-session.ts @@ -0,0 +1,99 @@ +// Sessione del portale cliente dopo la verifica OTP (v2.3). +// +// Stateless e firmata, come la sessione admin: nessuna tabella sessions da +// mantenere. Il payload porta client_id + email + istante di emissione, il tutto +// firmato HMAC-SHA256 con NEXTAUTH_SECRET — non falsificabile senza il segreto. +// +// La revoca lato admin non cancella cookie (non si può): alza +// clients.sessions_valid_from, e il gate scarta ogni sessione emessa prima. +// Per questo `iat` fa parte del payload firmato. +// +// Web Crypto, come admin-gate.ts e otp.ts: stesso modulo in edge e node. + +export const SESSION_MAX_AGE_SECONDS = 90 * 24 * 60 * 60; // 90 giorni + +export type ClientSession = { clientId: string; email: string; iat: number }; + +/** + * Un cookie per cliente: aprire il portale del cliente B non sloggia dal + * portale del cliente A. clientId è un nanoid — caratteri già validi in un + * nome di cookie. + */ +export function sessionCookieName(clientId: string): string { + return `ch_sess_${clientId}`; +} + +function b64urlEncode(s: string): string { + const bytes = new TextEncoder().encode(s); + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function b64urlDecode(s: string): string { + const binary = atob(s.replace(/-/g, "+").replace(/_/g, "/")); + const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); + return new TextDecoder().decode(bytes); +} + +async function sign(payload: string): Promise { + const secret = process.env.NEXTAUTH_SECRET; + if (!secret) throw new Error("NEXTAUTH_SECRET must be set"); + + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(`${secret}:client-session:v1`), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload)); + return Array.from(new Uint8Array(sig)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** Confronto a tempo costante — evita di far trapelare la firma dai tempi di risposta. */ +function safeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} + +export async function createSessionValue(clientId: string, email: string): Promise { + const payload = b64urlEncode( + JSON.stringify({ clientId, email, iat: Date.now() } satisfies ClientSession) + ); + return `${payload}.${await sign(payload)}`; +} + +/** + * Ritorna la sessione solo se: la firma è valida, il cookie appartiene a QUESTO + * cliente, e non ha superato la durata massima. Il confronto con + * sessions_valid_from (revoca admin) resta al chiamante, che ha già il record cliente. + */ +export async function verifySessionValue( + value: string | undefined, + clientId: string +): Promise { + if (!value) return null; + + const [payload, signature] = value.split("."); + if (!payload || !signature) return null; + + if (!safeEqual(await sign(payload), signature)) return null; + + let session: ClientSession; + try { + session = JSON.parse(b64urlDecode(payload)); + } catch { + return null; + } + + if (session.clientId !== clientId) return null; + if (typeof session.iat !== "number") return null; + if (Date.now() - session.iat > SESSION_MAX_AGE_SECONDS * 1000) return null; + + return session; +} diff --git a/src/lib/client-view.ts b/src/lib/client-view.ts index d91b580..f5b1e6a 100644 --- a/src/lib/client-view.ts +++ b/src/lib/client-view.ts @@ -158,6 +158,42 @@ export interface ClientProjectSummary { }>; } +/** + * Identità minima del cliente per il gate OTP: chi è, come si chiama il brand + * (per l'email) e da quando le sessioni sono valide (revoca admin). + * Nessun dato di progetto — il gate gira PRIMA che il cliente sia autenticato, + * quindi non deve caricare né esporre nulla di più. + * Ordine slug→token identico a getClientWithProjectsByToken e al proxy (D-06). + */ +export type ClientIdentity = { + id: string; + brand_name: string; + sessions_valid_from: Date | null; +}; + +export async function getClientIdentityByToken( + tokenOrSlug: string +): Promise { + const cols = { + id: clients.id, + brand_name: clients.brand_name, + sessions_valid_from: clients.sessions_valid_from, + }; + + try { + let rows = await db.select(cols).from(clients).where(eq(clients.slug, tokenOrSlug)).limit(1); + + if (rows.length === 0) { + rows = await db.select(cols).from(clients).where(eq(clients.token, tokenOrSlug)).limit(1); + } + + return rows[0] ?? null; + } catch (err) { + console.error("[client-view] getClientIdentityByToken error:", err); + return null; + } +} + /** * Resolves a token-or-slug to a client and returns the client's active projects. * Lookup order: slug first, then token — mirrors middleware order (D-06). diff --git a/src/lib/mailer.ts b/src/lib/mailer.ts new file mode 100644 index 0000000..45ca989 --- /dev/null +++ b/src/lib/mailer.ts @@ -0,0 +1,83 @@ +// Unico punto di invio email dell'app (Resend). +// +// Il chiamante DEVE poter distinguere "non ho inviato di proposito" da "l'invio +// è fallito": la route OTP risponde sempre con lo stesso messaggio al client +// (no enumeration), ma internamente deve sapere se Resend è giù per loggarlo. +// Per questo sendEmail non lancia e non ingoia — ritorna un Result esplicito. + +import { Resend } from "resend"; + +export type SendResult = + | { ok: true; id: string } + | { ok: false; error: string }; + +type SendInput = { + to: string; + subject: string; + html: string; +}; + +let client: Resend | null = null; + +/** Lazy: le env var vengono lette all'invio, non all'import del modulo. */ +function getClient(): Resend { + if (!client) { + const key = process.env.RESEND_API_KEY; + if (!key) throw new Error("RESEND_API_KEY must be set"); + client = new Resend(key); + } + return client; +} + +export async function sendEmail({ to, subject, html }: SendInput): Promise { + const from = process.env.RESEND_FROM; + if (!from) return { ok: false, error: "RESEND_FROM non configurata" }; + + try { + const { data, error } = await getClient().emails.send({ from, to, subject, html }); + + if (error) return { ok: false, error: error.message }; + if (!data) return { ok: false, error: "Resend non ha restituito un id" }; + + return { ok: true, id: data.id }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : "Errore invio email" }; + } +} + +// ── Template ──────────────────────────────────────────────────────────────── + +/** + * Email del codice OTP per l'accesso al portale cliente. + * HTML inline e minimale: i client di posta ignorano