feat(auth): gate OTP email sul portale cliente (v2.3 Phases 23-25)

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>
This commit is contained in:
2026-07-29 12:10:33 +02:00
parent b27b9d07ac
commit 8158038145
25 changed files with 1237 additions and 60 deletions
+54 -1
View File
@@ -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;
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;