Skip to content

Panel de administración ​

El panel tiene dos paquetes:

  • @tenancy-node/admin-api: la API HTTP (sin framework, sobre node:http). Hace todo el trabajo: autenticación, permisos, auditoría.
  • @tenancy-node/admin-ui: una SPA en React compilada a archivos estáticos. Solo habla con la API por HTTP.

El panel da acceso a todos los tenants. Trátalo como la parte más sensible de tu instalación. Decisiones y límites en ADR 0010 y ADR 0011.

Requisitos ​

  • El plugin de base de datos (@tenancy-node/db). Sin él, createAdminApi lanza InvalidConfigError. Usuarios, sesiones, auditoría y tokens de impersonación viven en tablas tenancy_admin_*, tenancy_audit_log y tenancy_impersonation_tokens de la base central (las crea tenancy install).
  • Un secreto de al menos 32 caracteres para sessionSecret. Con el CLI se lee de TENANCY_ADMIN_SECRET.
  • encryptionKey (TENANCY_KEY) en el plugin de base de datos si vas a usar 2FA o webhooks: el secreto TOTP y el secreto de firma de cada webhook se guardan cifrados. Sin llave, POST /auth/2fa/setup, POST /auth/2fa/enable y crear un webhook responden 501 TENANCY_ENCRYPTION_KEY_MISSING (y el error queda en el log).
bash
openssl rand -base64 48   # un valor para TENANCY_ADMIN_SECRET

Primer usuario ​

bash
npx tenancy install
npx tenancy admin:user tu@email.com --role=owner

Si no pasas contraseña, el CLI genera una y la muestra una sola vez. Para dar una propia sin que quede en el historial de la terminal, ponla en una variable de entorno:

bash
ADMIN_PASS='una-contraseña-larga' npx tenancy admin:user tu@email.com --password-env=ADMIN_PASS
npx tenancy admin:user tu@email.com --reset-password   # nueva contraseña y cierra sus sesiones
OpciónQué hace
--roleowner (por defecto), admin o support
--nameNombre visible. Por defecto, lo que va antes de @ en el email
--password-env=VARLee la contraseña de esa variable
--reset-passwordEl usuario ya existe: cambia su contraseña y cierra sus sesiones

La contraseña debe tener entre 12 y 256 caracteres. El alta queda en la auditoría (admin_user.create_cli o admin_user.password_reset_cli).

Levantar el panel ​

En su propio proceso (recomendado) ​

bash
TENANCY_ADMIN_SECRET=... npx tenancy admin:serve --port=4000 --ui
# API:  http://127.0.0.1:4000/admin/api
# UI:   http://127.0.0.1:4000/admin/
# Docs: http://127.0.0.1:4000/admin/api/openapi.json
OpciónPor defectoQué hace
--port4000Puerto
--host127.0.0.1Interfaz. Con otro valor el CLI avisa que el panel queda expuesto
--uiapagadoSirve también la interfaz en /admin (requiere @tenancy-node/admin-ui)
--insecure-cookiesapagadoCookies sin Secure. Solo para desarrollo local por http

Por defecto solo escucha en 127.0.0.1: entras por VPN o túnel SSH. Se detiene con SIGINT/SIGTERM.

Lo mismo desde código, con serveAdmin:

ts
import { createTenancy } from '@tenancy-node/core';
import { database } from '@tenancy-node/db';
import { postgres } from '@tenancy-node/db-postgres';
import { serveAdmin } from '@tenancy-node/admin-api';

const tenancy = createTenancy({
  centralDomains: ['tuapp.com'],
  plugins: [
    database({
      driver: postgres(),
      central: { url: process.env.DATABASE_URL! },
      encryptionKey: process.env.TENANCY_KEY, // necesaria para la 2FA del panel
    }),
  ],
});

const admin = await serveAdmin(tenancy, {
  sessionSecret: process.env.TENANCY_ADMIN_SECRET!, // mínimo 32 caracteres
  port: 4000, // por defecto 4000
  host: '127.0.0.1', // por defecto: solo local
  ui: true, // sirve @tenancy-node/admin-ui en /admin
});
console.log(admin.url); // http://127.0.0.1:4000/admin/api

process.once('SIGTERM', async () => {
  await admin.close();
  await tenancy.close();
});

serveAdmin también borra las sesiones vencidas cada hora.

Dentro de tu app ​

createAdminApi(tenancy, options) devuelve un objeto con handle(req, res): responde si la ruta empieza con el prefijo y devuelve false si no es del panel.

ts
import express from 'express';
import { tenancyMiddleware } from '@tenancy-node/adapter-express';
import { adminMiddleware, createAdminApi } from '@tenancy-node/admin-api';

const admin = createAdminApi(tenancy, { sessionSecret: process.env.TENANCY_ADMIN_SECRET! });

const app = express();
app.use(adminMiddleware(admin)); // antes del middleware de tenancy
app.use(tenancyMiddleware(tenancy));
app.listen(3000);
ts
import Fastify from 'fastify';
import { tenancyPlugin } from '@tenancy-node/adapter-fastify';
import { createAdminApi, registerAdminFastify } from '@tenancy-node/admin-api';

const admin = createAdminApi(tenancy, { sessionSecret: process.env.TENANCY_ADMIN_SECRET!, ui: true });

const app = Fastify();
registerAdminFastify(app, admin); // siempre antes de tenancyPlugin
await app.register(tenancyPlugin, { tenancy });
await app.listen({ port: 3000 });

registerAdminFastify se engancha en onRequest, antes de que Fastify lea el body, y toma la respuesta con reply.hijack().

El panel solo responde en los hosts permitidos: por defecto los dominios centrales, localhost, 127.0.0.1 y ::1. Una petición con el host de un tenant recibe 404.

Opciones ​

ts
const admin = createAdminApi(tenancy, {
  sessionSecret: process.env.TENANCY_ADMIN_SECRET!,
  prefix: '/admin/api', // la UI queda en el prefijo sin /api
  allowedHosts: ['admin.tuapp.com'], // por defecto: dominios centrales, localhost, 127.0.0.1 y ::1
  secureCookies: true, // false solo en desarrollo por http
  sessionTtlMs: 4 * 3_600_000, // por defecto 8 h
  loginRateLimit: { max: 5, windowMs: 15 * 60_000 },
  maskedColumns: /(password|secret|token|hash|salt|api_?key|otp|2fa|tarjeta)/i,
  publicDocs: false, // /openapi.json solo con sesión
  impersonationTtlSeconds: 60,
  impersonationUrl: ({ domain, token }) => `https://${domain}/entrar-como?token=${token}`,
  trustProxy: 1, // detrás de nginx: la IP real sale de X-Forwarded-For
  tenantFields: [
    { key: 'adminEmail', label: 'Email del administrador', type: 'email', required: true },
  ],
});
OpciónPor defectoQué hace
sessionSecretobligatorioSecreto de los tokens CSRF (mínimo 32 caracteres)
prefix/admin/apiPrefijo de la API
allowedHostsdominios centrales + localhost, 127.0.0.1, ::1Hosts desde los que responde
secureCookiestrueCookie con Secure
sessionTtlMs8 hDuración de la sesión
cookieNametenancy_adminNombre de la cookie
loginRateLimit{ max: 5, windowMs: 900000 }Intentos fallidos por IP y por email
impersonationTtlSeconds60Vida de los tokens de impersonación
impersonationUrlhttps://<dominio principal>/tenancy/impersonate?token=URL que abre la sesión en la app del tenant
maskedColumns/(password|secret|token|hash|salt|api_?key|otp|2fa)/iColumnas que el explorador oculta
bodyLimitBytes1 MiBTamaño máximo del body (413 si se pasa)
logoMaxBytes2 MiBTamaño máximo del logo que se sube desde el editor de tema
trustProxyfalseIP del cliente desde X-Forwarded-For (ver Detrás de un proxy)
clientIp—(req) => ip propia; tiene prioridad sobre trustProxy
tenantFields[]Campos de tenant.data que pide "Nuevo tenant" (ver Datos del tenant)
tenantUsers—Cómo listar y crear los usuarios de la app en cada tenant (ver Usuarios de cada tenant)
publicDocstrue/openapi.json sin autenticación
issuertenancy-nodeNombre que muestra la app de autenticación (2FA)
uiapagadotrue sirve @tenancy-node/admin-ui; o la ruta de una UI compilada
leadsapagadoFormulario público "Solicitar demo" (POST /leads). Ver Planes y módulos

serveAdmin acepta además port y host.

Detrás de un proxy ​

Detrás de nginx o un balanceador, todas las peticiones llegan desde la IP del proxy. Sin trustProxy, 5 logins fallidos de cualquier persona bloquean el login para todos durante 15 minutos, y la auditoría guarda la IP del proxy en todas las filas.

ts
createAdminApi(tenancy, { sessionSecret, trustProxy: 1 }); // un proxy (nginx) delante
  • Un número n confía en los últimos n saltos de X-Forwarded-For, contando desde el socket (como trust proxy de Express). Una IP falsa que agregue el cliente al principio del encabezado se ignora.
  • true toma la primera IP del encabezado: úsalo solo si el proxy reemplaza X-Forwarded-For (proxy_set_header X-Forwarded-For $remote_addr;).
  • Actívalo solo si el panel no se puede alcanzar sin pasar por el proxy: si no, cualquiera escribe el encabezado.
  • Con Express puedes pasar su cálculo: clientIp: (req) => (req as express.Request).ip.

En nginx:

nginx
location /admin/ {
  proxy_pass http://127.0.0.1:3000;
  proxy_set_header Host $host;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

Autenticación ​

  • Contraseñas con argon2id (19 MiB, 2 iteraciones, parámetros de OWASP). Mínimo 12 caracteres.
  • Login con POST /auth/login y { email, password, code?, mode? }. Un email inexistente responde igual y tarda lo mismo que uno con contraseña incorrecta.
  • Sesiones de 8 h por defecto. El token (32 bytes) solo existe del lado del cliente; en la base se guarda su SHA-256.
    • mode: 'cookie' (por defecto, para el navegador): cookie HttpOnly; SameSite=Strict; Secure. La respuesta trae csrfToken.
    • mode: 'token' (para automatizar): la respuesta trae token y lo mandas como Authorization: Bearer <token>.
  • CSRF: con cookie, toda petición que no sea GET debe traer X-CSRF-Token (HMAC de la sesión con sessionSecret). Si falta: 403 ADMIN_CSRF. Con Bearer no hace falta.
  • Límite de intentos: 5 fallos cada 15 min por IP y por email. Después, 429 ADMIN_TOO_MANY_ATTEMPTS con Retry-After. El contador vive en memoria del proceso: con varias réplicas del panel, cada una cuenta los suyos. Detrás de un proxy, configura trustProxy (Detrás de un proxy).
  • 2FA TOTP (RFC 6238, 6 dígitos, 30 s, acepta un paso de desfase). Se activa en dos pasos: POST /auth/2fa/setup devuelve el secreto y la URL otpauth://; POST /auth/2fa/enable lo confirma con un código. Con 2FA, el login sin code responde 401 ADMIN_2FA_REQUIRED. Requiere encryptionKey: sin ella los dos pasos responden 501 TENANCY_ENCRYPTION_KEY_MISSING.
  • Cambiar la contraseña cierra las demás sesiones. Cambiar el rol o desactivar a un usuario cierra todas las suyas.

Ejemplo para scripts:

bash
TOKEN=$(curl -s -X POST http://127.0.0.1:4000/admin/api/auth/login \
  -H 'content-type: application/json' \
  -d '{"email":"tu@email.com","password":"...","mode":"token"}' | jq -r .token)

curl -s http://127.0.0.1:4000/admin/api/tenants?status=failed -H "authorization: Bearer $TOKEN"

Roles ​

Cada ruta declara un permiso y el servidor lo verifica. La UI solo oculta lo que tu rol no puede hacer.

Permisosupportadminowner
Ver tenants, dominios, migraciones (tenants:read)✓✓✓
Explorador de datos (data:read)✓✓✓
Vaciar la caché de un tenant (cache:flush)✓✓✓
Impersonar (impersonate)✓✓✓
Ver webhooks y eventos (webhooks:read, events:read)✓✓✓
Métricas y errores (metrics:read)✓✓✓
Crear y cambiar tenants, dominios, tema; suspender, mantenimiento (tenants:write)✓✓
Migrar y correr seeds (migrations:run)✓✓
Crear y cambiar webhooks (webhooks:write)✓✓
Reintentar eventos de la outbox (events:retry)✓✓
Ver la auditoría (audit:read)✓✓
Borrar tenants (tenants:delete)✓
Administrar usuarios del panel (users:manage)✓

Siempre queda al menos un owner activo: no puedes degradar, desactivar ni borrar al último. Nadie puede borrarse a sí mismo. ROLES, can(role, permission) y permissionsOf(role) están exportados si los necesitas.

Qué hace la API ​

La lista completa, con cuerpos y parámetros, está en el documento OpenAPI 3.1 en <prefijo>/openapi.json (también admin.openapi() desde código). Resumen por área:

ÁreaRutas principales
auth/auth/login, /auth/logout, /auth/me, /auth/password, /auth/2fa/setup, /auth/2fa/enable, /auth/2fa/disable
tenantsGET/POST /tenants, GET/PATCH/DELETE /tenants/:id, /tenants/:id/suspend, /activate, /maintenance, /retry, /progress, /theme, /theme/logo, /cache/flush
dominiosGET/POST /tenants/:id/domains, DELETE /domains/:domain, POST /domains/:domain/primary
databaseGET /tenants/:id/migrations, POST /tenants/:id/migrate, POST /tenants/:id/seed, POST /migrate (todos, con concurrencia)
dataGET /tenants/:id/tables, GET /tenants/:id/tables/:table
webhooksGET/POST /webhooks, PATCH/DELETE /webhooks/:id, /webhooks/:id/deliveries, /webhooks/:id/test, /webhooks/deliveries/:id/redeliver (requiere @tenancy-node/transport-webhook)
eventosGET /events/outbox, POST /events/outbox/retry (requiere @tenancy-node/outbox)
systemGET /health (pública), GET /metrics, GET /errors, GET /audit, /users

Si falta el plugin de webhooks u outbox, esas rutas responden 404 ADMIN_FEATURE_DISABLED. Crear un webhook sin encryptionKey responde 501 TENANCY_ENCRYPTION_KEY_MISSING.

Borrar un tenant exige confirmar el id en el body: DELETE /tenants/bolivar con { "confirm": "bolivar" }.

Progreso en vivo (SSE) ​

POST /tenants y POST /tenants/:id/retry responden 202 y aprovisionan en segundo plano. El progreso se sigue con Server-Sent Events en GET /tenants/:id/progress:

EventoDatos
step{ runId, step, status, attempt, durationMs, error }
status{ status } cada vez que cambia el estado del tenant
done{ status } al llegar a active o failed; luego se cierra

El servidor lee los pasos de tenancy_provisioning_steps en la base, no de la memoria: funciona aunque el tenant se esté creando en otro proceso. Consulta cada 250 ms, manda un keep-alive cada 15 s y corta a los 10 minutos.

Datos del tenant ​

POST /tenants y PATCH /tenants/:id aceptan data (lo recibe el seed en tenant.data). PATCH combina las llaves; para borrar una, { "removeData": ["llave"] }.

Con tenantFields, el formulario "Nuevo tenant" pide esos campos (por ejemplo, el email del administrador inicial que el seed va a crear) y el servidor rechaza la creación si falta uno required (422, details: [{ path: 'data.adminEmail', ... }]). Además, el formulario y la pestaña Resumen del detalle permiten cargar y editar datos libres (llave → valor).

ts
createAdminApi(tenancy, {
  sessionSecret,
  tenantFields: [
    { key: 'adminEmail', label: 'Email del administrador', type: 'email', required: true },
    { key: 'telefono', label: 'Teléfono', type: 'tel', hint: 'Se muestra en la página pública' },
  ],
});

// En el seed:
seed: async (db, tenant) => {
  await crearAdministrador(db, String(tenant.data.adminEmail));
},

Usuarios de cada tenant ​

Los usuarios de tu app viven en la base de cada tenant, con tus tablas y tus reglas: el panel no los conoce. Con tenantUsers le dices cómo listarlos y crearlos, y el panel suma una pestaña Usuarios en cada tenant (buscar, crear con contraseña generada y "Entrar como") y un buscador de usuarios en el botón Entrar.

ts
import { AdminHttpError, createAdminApi } from '@tenancy-node/admin-api';

createAdminApi(tenancy, {
  sessionSecret,
  tenantUsers: {
    roles: [
      { value: 'ADMIN', label: 'Administrador' },
      { value: 'RECEPCION', label: 'Recepción' },
    ],
    // Corre en el contexto del tenant: tenancy.db() / tenancy.pool() apuntan a su base.
    async list({ search, page, perPage }) {
      let q = tenancy.db().selectFrom('users');
      if (search) q = q.where('full_name', 'like', `%${search}%`);
      const rows = await q.selectAll().limit(perPage).offset((page - 1) * perPage).execute();
      const { n } = await q.select((eb) => eb.fn.countAll().as('n')).executeTakeFirstOrThrow();
      return {
        items: rows.map((u) => ({ id: u.id, name: u.full_name, email: u.email, role: u.role, active: u.active })),
        total: Number(n),
      };
    },
    async create({ name, email, role, password }) {
      if (await emailExists(email)) throw new AdminHttpError(409, 'EMAIL_TAKEN', 'Ese email ya existe');
      return crearUsuario({ name, email, role, password }); // tu lógica: hash, reglas, auditoría
    },
  },
});
  • GET /tenants/:id/users?search&page&perPage (tenants:read) y POST /tenants/:id/users (tenants:write, body { name, email, role?, password? }).
  • El id de cada usuario es lo que recibe "Entrar como" (userIdentifier en consumeImpersonationToken).
  • La respuesta solo lleva id, name, email, role, active y lastLoginAt: aunque tu list devuelva un hash, no sale del servidor.
  • La creación queda en la auditoría (tenant_user.create) sin la contraseña. Si create lanza AdminHttpError, el panel muestra ese código y mensaje.
  • Sin create, el panel solo lista. Sin tenantUsers, las rutas responden 404 ADMIN_FEATURE_DISABLED y "Entrar" pide el usuario a mano.
  • Solo con el tenant aprovisionado: si no, 409 ADMIN_TENANT_NOT_READY.

Entrar a un tenant ​

El botón Entrar (en la lista y en el detalle) abre la app del tenant en otra pestaña con la sesión del usuario elegido, usando un token de un solo uso (Impersonación). La pestaña se abre en el mismo clic, así el navegador no la bloquea; si aun así no se puede abrir, el panel muestra el enlace para copiarlo.

GET /tenants/:id trae, además del tenant, effectiveTheme (su tema o, si no tiene, el theme.defaults de tu app), defaultTheme, logoUrl y previewDomain (su dominio principal, o <id>.<primer dominio central>). El editor parte de effectiveTheme: guardar sin tocar un color no lo cambia por otro.

PUT /tenants/:id/theme/logo sube el logo con la imagen como body (Content-Type: image/png, hasta logoMaxBytes):

  • Solo PNG, JPEG, GIF o WebP, reconocidos por su contenido (no por el Content-Type ni el nombre). SVG no: puede llevar scripts. Otro formato responde 415 ADMIN_UNSUPPORTED_IMAGE.
  • Se guarda en el almacenamiento del tenant (tenancy.storage(): disco local, S3 o compatible, según storage en createTenancy) como theme/logo-<hash>.<ext>, y esa ruta queda en theme.logo en la base central. El nombre cambia con cada logo, así ninguna caché sirve el anterior.
  • El logo anterior se borra si también lo había subido el panel.
  • Queda en la auditoría como theme.logo_upload.
bash
curl -X PUT https://tuapp.com/admin/api/tenants/bolivar/theme/logo \
  -H "authorization: Bearer $TOKEN" -H 'content-type: image/png' --data-binary @logo.png

Con Express, monta el panel antes de express.json()/express.raw(): si otro parser ya leyó el body, la subida responde 415 ADMIN_BODY_ALREADY_PARSED (salvo que sea un Buffer de express.raw()).

Explorador de datos ​

Solo lectura, dentro de la base del tenant (en modo schema, solo las tablas de su schema).

  • GET /tenants/:id/tables: tablas, columnas, tipo, cantidad de filas y si la columna está oculta.
  • GET /tenants/:id/tables/:table?page=1&perPage=50&sort=nombre&order=desc&filter.estado=activo: filas, máximo 100 por página.

Tabla y columnas se validan contra la estructura real y los valores van parametrizados. Las columnas que coinciden con maskedColumns salen como •••••• y no se pueden usar en filter. (403 ADMIN_MASKED_COLUMN). Cada lectura queda en la auditoría (data.view). No hay edición.

Impersonación ​

POST /tenants/:id/impersonate con { user, redirect? } crea un token de un solo uso para entrar como un usuario del tenant. Solo con el tenant activo. Responde { token, url, expiresAt }: url sale de impersonationUrl, o por defecto https://<dominio principal>/tenancy/impersonate?token=... (o null si el tenant no tiene dominio). También publica el evento admin.impersonation_started en el tenant.

Tu app canjea el token con consumeImpersonationToken. La ruta tiene que correr en el contexto del tenant:

ts
import express from 'express';
import { tenancyMiddleware } from '@tenancy-node/adapter-express';
import { consumeImpersonationToken } from '@tenancy-node/admin-api';

const app = express();
app.use(tenancyMiddleware(tenancy));

app.get('/entrar-como', async (req, res) => {
  // Lanza InvalidImpersonationTokenError si el token no sirve (vencido, usado o de otro tenant).
  const { userIdentifier, redirectPath } = await consumeImpersonationToken(
    tenancy,
    String(req.query.token),
  );
  await iniciarSesionComo(userIdentifier); // tu lógica de sesión
  res.redirect(redirectPath);
});

El token se guarda como hash, vence en 60 s por defecto, vale solo en su tenant y se marca como usado en la misma sentencia que lo valida. Al usarse deja un log warn con operation: 'admin.impersonation_used'. Cómo inicias la sesión del usuario es cosa de tu app.

Planes, solicitudes, avisos y cobros ​

Sección del panelAPIPermisoGuía
PlanesGET/POST /plans, PATCH/DELETE /plans/:id, GET /fonts, PATCH /tenants/:id { plan, features }tenants:read / plans:write, tenants:writePlanes y módulos
PlataformaGET/PATCH /platform, PUT /platform/logo, PUT /platform/favicontenants:read / platform:writeMarca de la plataforma
SolicitudesGET/PATCH/DELETE /leads, POST /leads (público con leads)leads:read / leads:writePlanes y módulos
AvisosGET/POST /notifications, POST /notifications/reach, POST /notifications/:id/deliver, DELETE /notifications/:idtenants:read / notifications:sendAvisos y push
CobrosGET /billing/invoices, GET /tenants/:id/billing, PUT /tenants/:id/billing/subscription, POST /billing/invoices/:id/payments, POST /billing/run, POST /billing/webhooks/:provider (público)billing:read / billing:writeCobros

support ve solicitudes y cobros; admin y owner además envían avisos, registran pagos y gestionan suscripciones.

Auditoría ​

Cada acción queda en tenancy_audit_log con usuario, IP, tenant, objetivo y changes (before/after cuando aplica). Se registran también los logins fallidos (auth.login_failed, con el motivo) y cada lectura del explorador. Nunca se guardan contraseñas ni tokens. La tabla no tiene claves foráneas: la historia sobrevive al borrado del tenant.

Se consulta con GET /audit?tenant=bolivar&user=3&action=tenant.&before=<id>&limit=50 (action filtra por prefijo; máximo 200 por página). Algunas acciones: auth.login, tenant.create, tenant.suspend, tenant.delete, domain.*, database.migrate, cache.flush, data.view, admin.impersonation_started, admin_user.update.

Seguridad HTTP ​

  • Solo JSON (415 si no), body máximo de 1 MiB (413 y cierre de la conexión).
  • Headers en cada respuesta: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer, Cache-Control: no-store y Content-Security-Policy: default-src 'none'; frame-ancestors 'none'.
  • Los errores 5xx responden ADMIN_INTERNAL_ERROR sin detalles. El detalle va al log (operation: 'admin.request') y al registro de errores.
  • Cada petición deja una línea de log con operation: 'admin.request', method, path (la plantilla), statusCode, adminUserId y durationMs.

La interfaz (@tenancy-node/admin-ui) ​

Instala @tenancy-node/admin-ui y pasa ui: true (o --ui en el CLI). La API busca el paquete desde el directorio actual y sirve su carpeta dist en el prefijo sin /api (/admin). También puedes pasar la ruta de una UI compilada: ui: './mi-ui/dist'.

Pantallas:

PantallaQué muestraPermiso
LoginEmail, contraseña y código 2FA—
DashboardTenants por estado, pools, salud, outbox y errores por tenantmetrics:read
TenantsLista con búsqueda y filtros; alta de tenant con progreso en vivotenants:read
Detalle de tenantPestañas Resumen (acciones: suspender, mantenimiento, borrar, impersonar), Dominios, Tema (editor con vista previa), Base de datos (migraciones), Datos (explorador) y Errorestenants:read (Datos: data:read)
WebhooksEndpoints, historial de entregas, reenvío y pruebawebhooks:read
EventosOutbox por estado y dead-letterevents:read
UsuariosUsuarios del panel y sus rolesusers:manage
AuditoríaEl registro de auditoría con filtrosaudit:read
Mi cuentaCambio de contraseña y 2FA—

La UI se sirve con una CSP estricta:

default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https:;
connect-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'none';
form-action 'self'; object-src 'none'

Nada inline ni de otros orígenes. Los assets con hash se cachean para siempre; index.html va con no-cache. Las rutas con ../ no salen de la carpeta de la UI. El token CSRF vive en memoria, no en localStorage. La UI está solo en español.

Límites conocidos ​

  • El límite de intentos de login y los errores de GET /errors son por proceso.
  • El explorador de datos no permite editar.
  • consumeImpersonationToken solo valida el token: iniciar la sesión del usuario lo haces tú.

Referencia completa: @tenancy-node/admin-api.

Publicado bajo la licencia MIT.