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>
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)
- Multi-tenant real: cada reformista ve SOLO sus leads/precios/catálogo; el admin ve todos.
- Alta de cuentas: self-signup trial público (RF-A-05) + creación/gestión por admin.
- Auth propio: email+password con sesión server-side (sin librería de auth externa).
- Planes = etiqueta + estado, sin enforcement: se asignan y se muestran; no bloquean funciones aún.
- La suscripción vive en el
tenant(= la cuenta), no en eluser. Un tenant puede tener varios users. - "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 entenantId.
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 entokenHash. - 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 ensessions.tokenHash; el token en claro va en cookiesession(httpOnly,secure,sameSite: lax,path: /). - Helpers en
src/lib/auth/:hashPassword(plain)/verifyPassword(plain, hash)createSession(userId)→ setea cookie ·destroySession()→ logoutgetCurrentUser()→ lee cookie, valida sesión, devuelve user o nullgetCurrentTenantId()→ deriva del user logueado (lanza si no hay)requireUser()/requireAdmin()→ para Server Components / actions; redirigen si falta permiso
getCurrentTenantId()reemplaza la resolución porTENANT_SLUGensrc/app/panel/actions.tsysrc/db/pricing-queries.ts. Las queries ya filtran portenantId; 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.mdantes de usarse (regla CLAUDE.md).
Control de acceso
- Reformista: todas las lecturas/mutaciones se filtran por su
tenantId(víagetCurrentTenantId()). 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
planIdysubscriptionStatusa un tenant desde/admin/planes. /panelmuestra un badge: p. ej. "Plan Pro · trial, 9 días restantes" (calculado desdetrialEndsAt).- No se bloquea ninguna función por plan ni por caducidad de trial en esta fase.
trialEndsAtse calcula y se muestra pero no corta el acceso. El gating efectivo se decidirá más adelante. - Stripe stub:
src/lib/billing/stripe.tsdefine 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.stripeCustomerIdqueda reservado.
Migración y seed
- Migración Drizzle nueva: crea enums (
user_role,user_status,subscription_status), tablas (users,sessions,plans) y columnas nuevas entenants. 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.
- Siembra los 3
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
trialEndsAtcorrecto; 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
- Auth base — enums + tablas
users/sessions; helperssrc/lib/auth/;/login+/logout; guard enpanel/layout; migrargetTenantIda sesión; seed admin + owner de "Reformas Ejemplo". - Signup trial —
/signuppúblico + cableado desde la landing B2B (CTA "Empieza gratis 14 días"); creación tenant+owner+trial; onboarding mínimo (redirect a/panel). - Área admin —
/admin,/admin/usuarios,/admin/planes; seed de los 3 planes; asignación de plan/estado. - 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).