diff --git a/docs/superpowers/specs/2026-05-30-auth-panel-planes-design.md b/docs/superpowers/specs/2026-05-30-auth-panel-planes-design.md new file mode 100644 index 0000000..fcfc100 --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-auth-panel-planes-design.md @@ -0,0 +1,173 @@ +# Auth + Multi-tenant + Admin de Planes — Diseño + +**Fecha:** 2026-05-30 +**Estado:** Aprobado (pendiente de plan de implementación) +**Superficie:** `mvp/b2c` (Next.js 16, Tailwind v4, Drizzle, postgres.js, Vitest) + +> **Nota de alcance:** Esta funcionalidad está marcada como **F1.5 / post-hackathon** en `specs.md` +> (multi-tenant real, líneas 39/259/265; pasarela de pago, línea 279). Se adelanta por decisión +> explícita del equipo (mayo 2026) tras abrir la conversación que CLAUDE.md exige para tocar F1.5. +> La demo del 11-jun **no depende** de este módulo; se construye desacoplado del pipeline de voz/render/WhatsApp. + +--- + +## Objetivo + +Convertir el panel single-tenant hardcodeado (`TENANT_SLUG` = "Reformas Ejemplo") en un SaaS con login +real, aislamiento de datos por reformista, y un área de administración que gestiona usuarios y asigna +planes. **Stripe queda como stub: sin cobro real** (no hay cuenta activa). + +## Decisiones clave (confirmadas en brainstorming) + +1. **Multi-tenant real:** cada reformista ve SOLO sus leads/precios/catálogo; el admin ve todos. +2. **Alta de cuentas:** self-signup trial público (RF-A-05) **+** creación/gestión por admin. +3. **Auth propio:** email+password con sesión server-side (sin librería de auth externa). +4. **Planes = etiqueta + estado, sin enforcement:** se asignan y se muestran; no bloquean funciones aún. +5. **La suscripción vive en el `tenant`** (= la cuenta), no en el `user`. Un tenant puede tener varios users. +6. **"Reformas Ejemplo" se reconvierte** en una cuenta logueable real para preservar la demo actual. + +--- + +## Arquitectura de datos + +### Tabla nueva: `users` + +| Campo | Tipo | Notas | +|---|---|---| +| `id` | uuid pk | | +| `email` | text unique notNull | | +| `passwordHash` | text notNull | bcryptjs | +| `nombre` | text | | +| `role` | enum `user_role` (`reformista`\|`admin`) notNull default `reformista` | | +| `tenantId` | uuid FK→tenants onDelete cascade, **nullable** | null para admin de plataforma; set para reformista | +| `status` | enum `user_status` (`activo`\|`deshabilitado`) notNull default `activo` | | +| `createdAt` / `updatedAt` | timestamptz notNull defaultNow | | + +- Relación **muchos users → un tenant** (soporta "usuarios ilimitados" del plan Business sin migración futura). +- Índice único en `email`. Índice en `tenantId`. + +### Tabla nueva: `sessions` + +| Campo | Tipo | Notas | +|---|---|---| +| `id` | uuid pk | | +| `userId` | uuid FK→users onDelete cascade | | +| `tokenHash` | text notNull | hash del token de sesión (el token en claro solo vive en la cookie) | +| `expiresAt` | timestamptz notNull | | +| `createdAt` | timestamptz notNull defaultNow | | + +- Índice en `userId`. Índice/único en `tokenHash`. +- Caducidad: 30 días deslizante (se renueva en cada request válido). Sesiones expiradas se ignoran y se limpian perezosamente. + +### Tabla nueva: `plans` + +| Campo | Tipo | Notas | +|---|---|---| +| `id` | uuid pk | | +| `slug` | text unique (`starter`\|`pro`\|`business`) | | +| `nombre` | text notNull | "Starter" / "Pro" / "Business" | +| `precioMensual` | integer notNull | céntimos: 2900 / 7900 / 19900 | +| `leadsIncluidos` | integer notNull | 5 / 15 / 50 | +| `features` | jsonb `$type` notNull default `[]` | bullets del copy | +| `activo` | boolean notNull default true | | + +Valores sembrados desde `copy/COPY-GUIDE.md` (§Pricing landing B2B): + +- **Starter** — 29 €/mes · 5 leads/mes · 3 €/lead extra · hasta 100 contactos · branding básico. +- **Pro** — 79 €/mes · 15 leads/mes · 2,50 €/lead extra · white-label · sub-flujo licencia urbanística · integraciones Holded/Stel · soporte prioritario. +- **Business** — 199 €/mes · 50 leads/mes · 2 €/lead extra · usuarios ilimitados · API · multi-zona · custom price book · dashboard analytics. + +### Columnas nuevas en `tenants` + +| Campo | Tipo | Notas | +|---|---|---| +| `planId` | uuid FK→plans, nullable | plan asignado | +| `subscriptionStatus` | enum `subscription_status` (`trial`\|`activo`\|`cancelado`\|`vencido`) notNull default `trial` | | +| `trialEndsAt` | timestamptz nullable | now + 14 días al crear vía signup | +| `stripeCustomerId` | text nullable | reservada para Stripe; sin uso real ahora | + +--- + +## Autenticación + +- **Hashing:** `bcryptjs` (JS puro, evita builds nativos en el Docker Node-22-slim). Dep nueva justificada en el commit. +- **Sesión server-side:** token aleatorio (`crypto.randomBytes`) → se guarda **hasheado** en `sessions.tokenHash`; el token en claro va en cookie `session` (`httpOnly`, `secure`, `sameSite: lax`, `path: /`). +- **Helpers** en `src/lib/auth/`: + - `hashPassword(plain)` / `verifyPassword(plain, hash)` + - `createSession(userId)` → setea cookie · `destroySession()` → logout + - `getCurrentUser()` → lee cookie, valida sesión, devuelve user o null + - `getCurrentTenantId()` → deriva del user logueado (lanza si no hay) + - `requireUser()` / `requireAdmin()` → para Server Components / actions; redirigen si falta permiso +- **`getCurrentTenantId()` reemplaza** la resolución por `TENANT_SLUG` en `src/app/panel/actions.ts` y `src/db/pricing-queries.ts`. Las queries ya filtran por `tenantId`; solo cambia la **fuente** del id. + +--- + +## Superficies / rutas + +| Ruta | Acceso | Función | +|---|---|---| +| `/signup` | público | RF-A-05: form email+nombre+empresa+provincia+opt-in → crea `tenant` + `user` (owner, role reformista) + `subscriptionStatus=trial`, `trialEndsAt=now+14d`. Redirige a `/panel`. Cableado desde el CTA "Empieza gratis 14 días" de la landing B2B | +| `/login` | público | email+password → crea sesión → `/panel` | +| `/logout` | autenticado | destruye sesión → `/login` | +| `/panel/*` | reformista | guard en `panel/layout.tsx`: sin sesión → redirect `/login`. Muestra SOLO datos de su tenant. Badge de plan/estado en la cabecera | +| `/admin` | admin | `requireAdmin`. Lista tenants + usuarios; ver planes | +| `/admin/usuarios` | admin | crear reformista (crea tenant+user owner), habilitar/deshabilitar usuarios | +| `/admin/planes` | admin | ver catálogo de planes; **asignar plan + `subscriptionStatus`** a un tenant | + +> El área admin reutiliza el estilo del panel existente (Tailwind v4, sin shadcn). Copy nuevo que no exista +> se añade primero a `copy/COPY-GUIDE.md` antes de usarse (regla CLAUDE.md). + +--- + +## Control de acceso + +- **Reformista:** todas las lecturas/mutaciones se filtran por su `tenantId` (vía `getCurrentTenantId()`). No puede acceder a leads, precios ni catálogo de otro tenant. Acceso a `/admin/*` → 403/redirect. +- **Admin** (`tenantId = null`, `role = admin`): ve y gestiona todos los tenants y usuarios; asigna planes y cambia estado de suscripción. No tiene un "panel de leads" propio (no posee tenant); opcionalmente puede impersonar/ver un tenant en modo lectura — **fuera de alcance ahora**. + +--- + +## Planes (sin enforcement) + +- El admin asigna `planId` y `subscriptionStatus` a un tenant desde `/admin/planes`. +- `/panel` muestra un badge: p. ej. *"Plan Pro · trial, 9 días restantes"* (calculado desde `trialEndsAt`). +- **No se bloquea ninguna función por plan ni por caducidad de trial** en esta fase. `trialEndsAt` se calcula y se muestra pero no corta el acceso. El gating efectivo se decidirá más adelante. +- **Stripe stub:** `src/lib/billing/stripe.ts` define la interfaz (`createCustomer`, `createCheckoutSession`, etc.) pero **no hace llamadas reales**; lanza o devuelve no-op documentado. Botón "Gestionar pago" en el panel deshabilitado con copy "Próximamente". `tenants.stripeCustomerId` queda reservado. + +--- + +## Migración y seed + +- **Migración Drizzle nueva:** crea enums (`user_role`, `user_status`, `subscription_status`), tablas (`users`, `sessions`, `plans`) y columnas nuevas en `tenants`. Solo añade; no rompe datos existentes. +- **Seed** (gateado por `SEED_FORCE`, comportamiento TRUNCATE+reinsert ya existente): + - Siembra los 3 `plans` (Starter/Pro/Business). + - Crea **1 admin** (email+password de demo, documentado). + - Reconvierte "Reformas Ejemplo" en tenant con **1 user owner logueable** (email+password de demo), `planId` = Pro, `subscriptionStatus` = trial, `trialEndsAt` = now+14d — así toda la demo de leads/presupuesto actual sigue accesible tras login. + +--- + +## Testing (Vitest) + +- **Auth helpers:** `hashPassword`/`verifyPassword` (round-trip y rechazo), `createSession`/validación, caducidad de sesión. +- **Control de acceso:** reformista del tenant A no obtiene leads del tenant B; admin sí ve ambos; usuario no-admin es rechazado en acciones admin. +- **Signup:** crea tenant + user owner + trial con `trialEndsAt` correcto; email duplicado rechazado. +- **Asignación de plan:** admin asigna `planId`/`subscriptionStatus`; se refleja en el tenant. +- Cobertura objetivo de la lógica de auth/acceso alineada con RNF-MAINT-01 (≥70% en módulos nuevos críticos). + +--- + +## Fases de implementación + +1. **Auth base** — enums + tablas `users`/`sessions`; helpers `src/lib/auth/`; `/login` + `/logout`; guard en `panel/layout`; migrar `getTenantId` a sesión; seed admin + owner de "Reformas Ejemplo". +2. **Signup trial** — `/signup` público + cableado desde la landing B2B (CTA "Empieza gratis 14 días"); creación tenant+owner+trial; onboarding mínimo (redirect a `/panel`). +3. **Área admin** — `/admin`, `/admin/usuarios`, `/admin/planes`; seed de los 3 planes; asignación de plan/estado. +4. **Stripe stub + display de plan** — `src/lib/billing/stripe.ts` (interfaz no-op); badge de plan/estado en `/panel`; botón "Gestionar pago" deshabilitado. + +--- + +## Fuera de alcance (explícito) + +- Cobro real / checkout Stripe / webhooks de facturación. +- Enforcement de límites por plan (leads/mes, renders) y bloqueo por trial vencido. +- OAuth / login social, recuperación de contraseña por email, verificación de email. +- Impersonación de tenant por el admin (vista de leads ajena). +- Gestión multi-usuario dentro de un mismo tenant desde el panel del reformista (el schema lo soporta, la UI no se construye ahora).