Add design spec for auth, multi-tenant y admin de planes
Diseño aprobado del módulo de login del reformista, aislamiento multi-tenant y área admin con asignación de planes (Stripe en stub). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
173
docs/superpowers/specs/2026-05-30-auth-panel-planes-design.md
Normal file
173
docs/superpowers/specs/2026-05-30-auth-panel-planes-design.md
Normal file
@@ -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<string[]>` 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).
|
||||
Reference in New Issue
Block a user