# 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).