Files
reformix-hackaton/docs/superpowers/specs/2026-05-30-auth-panel-planes-design.md
Carlos Narro 902062d443 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>
2026-05-30 19:09:05 +02:00

9.8 KiB

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 plansrc/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).