Skip to content

Flujo de Superadmin (Plataforma)

Base URL: /api/v1 Implementado en: camaroneras_backend (superficie src/modules/auth/platform + módulo src/modules/platform) y camaroneras_platform (panel web dedicado, cross-tenant)

Requests y responses 2xx: bodies (tipos, opcionales, enums) y el shape de las respuestas exitosas están en la API Reference, generada desde el spec OpenAPI. Acá quedan solo las respuestas de error (4xx/5xx) y las reglas de negocio/flujo.

Resumen

Capa de plataforma: un superadmin/operador que opera por encima de las organizaciones (cross-tenant). Tiene su propia identidad, su propia superficie de autenticación, un módulo de supervisión (backend) y un panel web dedicado (camaroneras_platform, puerto dev 5174).

Identidad de plataforma

  • Un User puede tener platformRoleIdPlatformRole (tabla propia, ver "RBAC de plataforma" abajo). null = usuario normal de tenant. Un usuario de plataforma no necesita OrganizationUser.
  • platformActive (bool, default true): desactivación propia de plataforma, independiente de status (que es del lado tenant). Un mismo usuario puede ser miembro activo de una organización y operador desactivado de plataforma, o viceversa.
  • Flag mustChangePassword: el bootstrap (y la creación de operadores desde el panel) crean al usuario con una credencial temporal; el cliente debe forzar el cambio en el primer login.

Bootstrap del primer owner (seguro, auto-desactivable)

Al arrancar (onModuleInit), si ambas variables SUPERADMIN_EMAIL / SUPERADMIN_PASSWORD están definidas y no existe ningún usuario con platformRoleId:

  • Si el email no existe → crea el usuario con rol Owner, contraseña temporal y mustChangePassword=true.
  • Si el email ya existe (usuario de tenant) → solo lo promueve a Owner (no toca su contraseña, que comparte con su identidad de tenant).

No hay endpoint público de creación de superadmins — esa es la única vía de arranque. Operadores adicionales se crean después desde POST /platform/users (ver RBAC abajo), por alguien con permiso usuarios-plataforma:crear.

RBAC de plataforma

Sistema de roles y permisos propio de la plataforma, aislado del RBAC de tenant (Role/Permission, ver plataforma/rbac-flow.md): tablas separadas, guard separado, cache Redis con prefijo separado (perms:platform-role:* vs perms:role:*). No comparten nada — ver ADR-0048 para la decisión de diseño (por qué no se reusó el modelo de tenant).

  • PlatformRole: id, name (único), isSystem. Dos roles de sistema sembrados por migración y re-sembrados en cada boot (idempotente, resiliente a un reset de BD): Owner (todos los permisos) y Operator (operador de soporte, ver matriz abajo). Los roles de sistema no se pueden renombrar, editar ni eliminar (403).
  • PlatformPermission: par (moduleId, actionId) por rol. A diferencia del catálogo de tenant, los módulos/acciones de plataforma viven solo en código (platform-rbac.constants.ts), no en tablas — son módulos internos que cambian con el deploy, no por tenant.
  • Módulos: organizaciones, usuarios-plataforma, roles-plataforma, metricas, usuarios-tenant, roles-tenant (estos dos últimos no supervisan la plataforma en sí, sino que gatean la gestión cross-tenant de usuarios/roles de una organización concreta — ver la sección dedicada más abajo). Acciones: ver / crear / editar / eliminar / exportar (mismo set que tenant — hoy ningún módulo de plataforma implementa export, pero Owner lo tiene igual que Administrador tiene todas las acciones de tenant aunque no todas se usen todavía).
  • Roles custom: se pueden crear roles de plataforma adicionales (isSystem=false) con cualquier combinación de permisos del catálogo.

Operator: operador de soporte

Decisión de producto (2026-07-23, ver ADR-0052): Operator pasó de solo-lectura a poder ayudar a un tenant sin poder escalar privilegios de plataforma.

MóduloAccionesPor qué
organizacionesver, editarpuede corregir/activar-desactivar datos de una org
usuarios-tenantver, crear, editaraltas/ediciones de usuarios de un tenant
roles-tenantversolo lectura de roles de tenant
usuarios-plataformaverno crea usuarios de plataforma (cierra la escalada)
roles-plataformaverno amplía roles de plataforma, ni el propio (misma razón)
metricasverdashboard

El vector de escalada queda cerrado: Operator no tiene ninguna acción de escritura sobre usuarios-plataforma/roles-plataforma, así que no puede fabricarse (ni fabricarle a otro) permisos de Owner.

Invariantes de seguridad

  1. No quedarse sin nadie que administre la plataforma. No se puede desactivar, ni cambiar el rol, al último usuario activo con un rol que tenga usuarios-plataforma:crear. 400 (No puedes dejar la plataforma sin nadie que pueda administrar usuarios...). También se chequea a nivel de rol: no se le puede quitar usuarios-plataforma:crear a un rol vía PUT /platform/roles/:id/permissions si eso dejaría a la plataforma sin ningún otro usuario activo que pueda crear usuarios — el choke point real es la matriz del rol, no solo la asignación individual.
  2. No auto-escalada. Un usuario no puede cambiar su propio rol (PATCH /platform/users/:id/role con id = el propio) → 403. Tampoco puede editar los permisos del rol que él mismo tiene (PUT /platform/roles/:id/permissions) → 403.
  3. Rol de sistema intocable. Owner/Operator no se renombran, no se les edita la matriz de permisos, no se eliminan → 403.
  4. Cache sin ventana de gracia. Revocar un permiso de un rol invalida su cache Redis después de confirmar la transacción: el siguiente request de cualquier usuario con ese rol ya no lo tiene, sin reemitir sesión.
  5. Aislamiento de scope (ver sección de tokens abajo): un token de tenant nunca entra a /platform/*, y viceversa.
  6. Desactivación con efecto inmediato. platformActive=false lo chequea PlatformPasswordChangeGuard en cada request a /platform/* (lookup en BD, mismo guard que ya chequeaba mustChangePassword) — no solo en signin/refresh. Un access token emitido antes de la desactivación deja de servir en la siguiente request, en vez de seguir siendo válido hasta expirar.

Tipos de token

TokenScopeSecretDuración (env)Sirve para
platform accessplatformJWT_ACCESS_SECRET15mSuperficie de plataforma (sin organizationId)
platform refreshJWT_REFRESH_SECRET7dRenovar (hasheado en BD, rota); cookie path /api/v1/auth/platform

Aislamiento de superficies (lo más sensible de esta capa): el access token de plataforma lleva scope: platform y no lleva organizationId. La separación con la sesión de tenant (scope: full) es por scope, cada estrategia rechaza el token del scope ajeno:

  • Token de tenant → endpoint de plataforma (/platform/*, PlatformGuard) → 403 (autenticado, pero prohibido cruzar).
  • Token de plataforma → endpoint de tenant (AccessJwtGuard, ej. /auth/me) → 401.

Auth de plataforma

POST /auth/platform/signin — público

Inicia sesión de plataforma. Valida credenciales y que el usuario tenga platformRole. Emite access token de scope platform + refresh (cookie httpOnly, path /auth/platform; también en el body para mobile/Postman). Devuelve user.mustChangePassword para que el cliente decida forzar el cambio, y user.permissions: string[] (mismo formato modulo:accion que el resto del RBAC) — el panel lo usa para el nav/gating de UI. No es la autorización real: es una caché de conveniencia calculada con getPermissionsForRole en el momento de armar la respuesta; el backend sigue resolviendo cada request contra Redis/BD vía PlatformPermissionGuard, no contra este valor. refresh y me devuelven el mismo campo, por el mismo motivo.

json
// response 401 (credenciales inválidas o usuario sin platformRole)
{ "message": "Credenciales inválidas", "error": "Unauthorized", "statusCode": 401 }

Mensaje neutro: no revela si el email existe ni si es de plataforma.

POST /auth/platform/refresh — refresh token (cookie o body)

Rota el refresh token. Re-verifica platformRole al rotar: si al usuario se le quitó el rol, el refresh no revive la sesión de plataforma.

json
// response 401 (refresh ausente, inválido, expirado, o usuario sin platformRole)
{ "message": "Refresh token inválido o expirado", "error": "Unauthorized", "statusCode": 401 }

POST /auth/platform/signout — Bearer platform token

Revoca el refresh token y limpia la cookie. Idempotente (200 aunque el token ya no exista).

GET /auth/platform/me — Bearer platform token

Devuelve el usuario de plataforma (id, email, name, platformRole: {id, name}, mustChangePassword, permissions: string[] — ver nota en signin).

json
// response 401 (token ausente/inválido, usuario ya sin rol de plataforma, o desactivado)
{ "message": "Usuario no encontrado", "error": "Unauthorized", "statusCode": 401 }

POST /auth/platform/change-password — Bearer platform token

Cambia la contraseña: valida la actual, setea la nueva, limpia mustChangePassword y revoca todas las sesiones (refresh tokens) del usuario.

json
// response 401 (contraseña actual incorrecta)
{ "message": "La contraseña actual es incorrecta", "error": "Unauthorized", "statusCode": 401 }

Supervisión + RBAC (módulo platform)

Todos detrás de PlatformJwtGuard (scope platform) + PlatformPasswordChangeGuard+ PlatformPermissionGuard: mientras el usuario tenga mustChangePassword=true o platformActive=false, ningún endpoint de /platform/* responde — solo /auth/platform/me, /signout y /change-password quedan accesibles (no llevan el segundo guard). Cada endpoint además exige su propio permiso (@RequirePlatformPermission('modulo', 'accion')); sin el permiso → 403, nunca un 200 silencioso. Ambos chequeos de guard son server-side (lookup en BD, no en el JWT claim): apenas cambian, el mismo access token ya refleja el estado nuevo en el siguiente request, sin reemitir sesión. Cross-tenant: las queries de supervisión no se filtran por organizationId.

json
// response 403 (mustChangePassword=true, endpoint de /platform/*)
{ "message": "Debes cambiar tu contraseña temporal antes de continuar", "error": "Forbidden", "statusCode": 403 }
json
// response 403 (platformActive=false)
{ "message": "Tu acceso de plataforma está desactivado", "error": "Forbidden", "statusCode": 403 }
json
// response 403 (sin el permiso requerido para el endpoint)
{ "message": "No tienes permiso para esta acción", "error": "Forbidden", "statusCode": 403 }

Supervisión

  • GET /platform/metrics — (metricas:ver) organizaciones (total/activas/inactivas), usuarios (total), membresías (total), altas recientes (30 días). users.total y recentSignups.count cuentan solo usuarios reales: status='active' y sin rol de plataforma (platformRoleId: null) — excluye invitados/abandonados (pending_verification/invited) y a los propios operadores de plataforma.

  • GET /platform/organizations — (organizaciones:ver) lista mixta: organizaciones reales (kind: 'organization') e invitaciones delegadas pendientes sin organización todavía (kind: 'pending_invitation', ver el modo delegado de POST abajo). Cada item trae isActive, nº de usuarios, nº de piscinas, y pendingAdminInvitation: {email, expiresAt} | null. En una fila pending_invitation, id es el del usuario invitado (no hay organización), name/slug son null, y pendingAdminInvitation nunca es null. Las filas delegadas se devuelven siempre completas y al principio, sin paginar — son pocas y transitorias; meta.total/meta.pageSize describen únicamente las organizaciones reales, así que el cliente no debe asumir data.length <= meta.pageSize. Todo batcheado (una query adicional por tipo), no N+1.

  • PATCH /platform/organizations/:id — (organizaciones:editar) activa/desactiva una organización. Con efecto real: al desactivarla, el signin/refresh de tenant de sus usuarios quedan bloqueados (401) hasta reactivarla.

    json
    // response 404 (organización no existe)
    { "message": "Organización no encontrada", "error": "Not Found", "statusCode": 404 }
  • POST /platform/organizations — (organizaciones:crear) name es opcional — define el modo:

    • Directo (name presente): crea la organización (slug único, con reintento acotado ante colisión), los 3 roles por defecto + su matriz de permisos y las 4 curvas de alimentación de referencia (mismo seed que POST /auth/onboarding, ver plataforma/rbac-flow.md), y un User con status: invited ya miembro como primer Administrador. Respuesta: organization: {id, name, slug}.
    • Delegado (name ausente): crea solo el User invitado — sin organización, sin roles, sin curvas, sin membresía. El propio invitado nombra su empresa al aceptar (POST /auth/onboarding, misma secuencia de siembra pero disparada por el cliente en vez de por plataforma — ver plataforma/auth-flow.md, sección Onboarding). Respuesta: organization: null.

    En ambos modos: invita al admin por email (magic link, VerificationTokenService, TTL 7 días) — el link apunta al panel de tenant (config.app.webUrl), nunca al de plataforma. Se acepta con POST /auth/accept-invitation, que bifurca según si ya hay organización o no (ver plataforma/rbac-flow.md). Si el envío del email falla, el alta (con o sin org) ya quedó creada — se recupera con alguno de los dos reenvíos de abajo, no se pierde.

    json
    // response 409 (el email del admin ya está registrado en el sistema)
    { "message": "Ese email ya está registrado en el sistema", "error": "Conflict", "statusCode": 409 }
  • POST /platform/organizations/:id/resend-invitation — (organizaciones:editar) reenvía la invitación al primer admin de una organización ya creada (modo directo): invalida el token vigente y emite uno nuevo (TTL 7 días desde el reenvío).

    json
    // response 404 (la organización no existe)
    { "message": "Organización no encontrada", "error": "Not Found", "statusCode": 404 }
    json
    // response 400 (sin invitación de admin pendiente — ya aceptó, o la org no se creó por esta vía)
    { "message": "Esta organización no tiene una invitación de administrador pendiente", "error": "Bad Request", "statusCode": 400 }
  • POST /platform/invitations/:userId/resend — (organizaciones:editar) equivalente al anterior pero para una invitación delegada (sin organización): no hay :id de org para identificar al invitado, así que se busca por userId (el mismo id que trae la fila pending_invitation del listado). Mismo TTL; invalida y reemite.

    json
    // response 404 (el usuario no existe)
    { "message": "Usuario no encontrado", "error": "Not Found", "statusCode": 404 }
    json
    // response 400 (ya tiene organización, o ya aceptó — no es una invitación delegada pendiente)
    { "message": "Este usuario no tiene una invitación delegada pendiente", "error": "Bad Request", "statusCode": 400 }
  • POST /platform/organizations/:id/cancel-invitation — (organizaciones:editar) cancela la invitación pendiente del primer admin de una organización ya creada (modo directo): borra por completo al usuario invitado (libera el email). Mismas condiciones de error que el reenvío de arriba (404 sin org, 400 sin invitación pendiente) — ver plataforma/admin-ui-flow.md para el flujo de cliente (OrganizationsPage, botón "Cancelar invitación" con confirmación).

    json
    // response 404 (la organización no existe)
    { "message": "Organización no encontrada", "error": "Not Found", "statusCode": 404 }
    json
    // response 400 (sin invitación de admin pendiente)
    { "message": "Esta organización no tiene una invitación de administrador pendiente", "error": "Bad Request", "statusCode": 400 }
  • POST /platform/invitations/:userId/cancel — (organizaciones:editar) equivalente al anterior pero para una invitación delegada (sin organización): borra por completo al usuario invitado (libera el email).

    json
    // response 404 (el usuario no existe)
    { "message": "Usuario no encontrado", "error": "Not Found", "statusCode": 404 }
    json
    // response 400 (ya tiene organización, o ya aceptó — no es una invitación delegada pendiente)
    { "message": "Este usuario no tiene una invitación delegada pendiente", "error": "Bad Request", "statusCode": 400 }

Roles de plataforma (/platform/roles, /platform/catalog/modules)

  • GET /platform/catalog/modules — (roles-plataforma:ver) catálogo de módulos/acciones para la matriz de permisos.
  • GET /platform/roles — (roles-plataforma:ver) roles con isSystem, userCount, permissionCount.
  • POST /platform/roles — (roles-plataforma:crear) crea un rol custom (isSystem=false).
    json
    // response 400 (nombre duplicado)
    { "message": "Ya existe un rol con ese nombre", "error": "Bad Request", "statusCode": 400 }
  • PATCH /platform/roles/:id — (roles-plataforma:editar) renombra. 403 si isSystem.
  • DELETE /platform/roles/:id — (roles-plataforma:eliminar) 403 si isSystem; 400 si tiene usuarios asignados.
  • GET /platform/roles/:id/permissions — (roles-plataforma:ver) pares {module, action}.
  • PUT /platform/roles/:id/permissions — (roles-plataforma:editar) reemplaza la matriz completa. 403 si isSystem o si el rol es el propio del caller (invariante 2). 400 si algún par module:action no está en el catálogo.

Usuarios de plataforma (/platform/users)

  • GET /platform/users — (usuarios-plataforma:ver) cross-tenant: email, name, status, platformRole: {id, name} | null, platformActive, nº de organizaciones. Con ?platformOnly=true filtra a solo usuarios con rol de plataforma (operadores) — lo usa la pantalla "Operadores" del panel para no traer el listado cross-tenant completo. Sin el param, comportamiento actual intacto.
  • POST /platform/users — (usuarios-plataforma:crear) crea un operador con contraseña temporal (mustChangePassword=true). Mismo patrón que el bootstrap: sin auto-registro.
    json
    // response 409 (email ya registrado)
    { "message": "Ese email ya está registrado en el sistema", "error": "Conflict", "statusCode": 409 }
  • PATCH /platform/users/:id/role — (usuarios-plataforma:editar) cambia el rol. 403 si id es el propio usuario (invariante 2). 400 si dejaría a la plataforma sin nadie que pueda crear usuarios (invariante 1).
  • PATCH /platform/users/:id/deactivate — (usuarios-plataforma:eliminar) revoca sesiones activas (refresh tokens) y pone platformActive=false. 400 si es el último manager (invariante 1) — a diferencia del rol, se puede desactivar a uno mismo si no es el último.
  • PATCH /platform/users/:id/reactivate — (usuarios-plataforma:editar) platformActive=true.

Gestión cross-tenant de usuarios y roles de una organización

Permite al superadmin administrar, desde plataforma, los usuarios y roles de UNA organización concreta — sin pedirle credenciales al tenant. Es un passthrough puro sobre los mismos UsersService/RolesService que usa (usaba) camaroneras_admin: las invariantes de negocio de tenant (último administrador activo, unicidad de nombre de rol, rol por defecto intocable) se preservan sin reimplementarse. Los endpoints tenant /users//roles originales se conservan intactos — ver ADR-0051.

organizationId viene siempre del param :orgId, nunca del token (el actor es el superadmin, no un miembro de la organización). Cada handler valida primero que la organización exista:

json
// response 404 (organización no existe, cualquier endpoint de esta sección)
{ "message": "Organización no encontrada", "error": "Not Found", "statusCode": 404 }

Usuarios (/platform/organizations/:orgId/users)

  • GET '' — (usuarios-tenant:ver) listar usuarios de la organización.
  • POST 'invite' — (usuarios-tenant:crear) invitar (magic link, mismo flujo que plataforma/rbac-flow.md).
    json
    // response 409 (ya invitaste a ese email en esta misma organización)
    { "message": "Ya invitaste a este email. Usa \"Reenviar invitación\".", "error": "Conflict", "statusCode": 409 }
    json
    // response 409 (email ya registrado en el sistema, en otra org)
    { "message": "Ese email ya está registrado en el sistema", "error": "Conflict", "statusCode": 409 }
    json
    // response 400 (roleId no pertenece a esta organización)
    { "message": "Rol inválido", "error": "Bad Request", "statusCode": 400 }
  • PATCH ':userId/role' — (usuarios-tenant:editar) cambiar rol.
    json
    // response 400 (dejaría a la organización sin administrador)
    { "message": "No puedes dejar la organización sin un administrador. Asigna un rol con gestión de roles a otro usuario primero.", "error": "Bad Request", "statusCode": 400 }
  • PATCH ':userId/deactivate' — (usuarios-tenant:editar) desactivar (revoca sesiones).
    json
    // response 400 (es el único administrador activo de la organización)
    { "message": "No puedes desactivar al único administrador. Asigna el rol de administrador a otro usuario primero.", "error": "Bad Request", "statusCode": 400 }
  • PATCH ':userId/reactivate' — (usuarios-tenant:editar) reactivar.
  • POST ':userId/cancel-invitation' — (usuarios-tenant:editar) cancelar invitación pendiente (borra el usuario invitado, libera el email).
    json
    // response 400 (el usuario ya no está pendiente de invitación)
    { "message": "Solo se pueden cancelar invitaciones pendientes", "error": "Bad Request", "statusCode": 400 }
  • POST ':userId/resend-invitation' — (usuarios-tenant:editar) reenviar invitación (invalida el token anterior, emite uno nuevo).
    json
    // response 400 (el usuario ya no está pendiente de invitación)
    { "message": "La cuenta ya no está pendiente de invitación", "error": "Bad Request", "statusCode": 400 }

Cualquier :userId que no sea miembro de la organización → 404 (Usuario no encontrado).

Roles (/platform/organizations/:orgId/roles)

  • GET '' — (roles-tenant:ver) listar roles de la organización.
  • POST '' — (roles-tenant:crear) crear rol.
    json
    // response 400 (nombre duplicado en esta organización)
    { "message": "Ya existe un rol con ese nombre", "error": "Bad Request", "statusCode": 400 }
  • PATCH ':roleId' — (roles-tenant:editar) renombrar.
    json
    // response 403 (rol por defecto de la organización — Administrador/Técnico/Bodeguero)
    { "message": "No se puede renombrar un rol por defecto", "error": "Forbidden", "statusCode": 403 }
  • DELETE ':roleId' — (roles-tenant:eliminar) eliminar.
    json
    // response 403 (rol por defecto)
    { "message": "No se puede eliminar un rol por defecto", "error": "Forbidden", "statusCode": 403 }
    json
    // response 400 (tiene usuarios asignados)
    { "message": "No se puede eliminar un rol con usuarios asignados", "error": "Bad Request", "statusCode": 400 }
  • GET ':roleId/permissions' — (roles-tenant:ver) pares {module, action} del rol.
  • PUT ':roleId/permissions' — (roles-tenant:editar) reemplaza la matriz completa (invalidación Redis automática, igual que el endpoint tenant).
    json
    // response 400 (par module:action fuera del catálogo)
    { "message": "Permiso inválido: <module>:<action>", "error": "Bad Request", "statusCode": 400 }

Cualquier :roleId que no pertenezca a la organización → 404 (Rol no encontrado).

Catálogo tenant (GET /platform/tenant-catalog/modules)

  • (roles-tenant:ver) catálogo de tenant (tablas Module/Action — el mismo que usa GET /catalog/modules del lado tenant, ver plataforma/rbac-flow.md), expuesto bajo guard de plataforma. Es distinto del catálogo de plataforma (GET /platform/catalog/modules, solo-código, ver arriba): la matriz de permisos de esta sección edita roles de tenant, así que necesita el catálogo de tenant.

Errores comunes (toda la superficie)

json
// response 401 (sin token / token inválido)
{ "message": "Unauthorized", "statusCode": 401 }
json
// response 403 (token de tenant intentando entrar a /platform/*)
{ "message": "Se requiere un token de plataforma", "error": "Forbidden", "statusCode": 403 }

Flujo de cliente (camaroneras_platform)

Panel React + Vite + TS + Shadcn (mismo stack que camaroneras_admin, puerto dev fijo 5174). Consume solo /auth/platform/* y /platform/* — nunca la superficie de tenant.

Rutas

  • Pública: /login.
  • Protegida, sin gate de password: /change-password (para no auto-bloquearse).
  • Protegidas con gate de sesión, sin gate de permiso: / (dashboard) — ver nota abajo.
  • Protegidas con gate de sesión + permiso: /organizations (organizaciones:ver), /operators (usuarios-plataforma:ver), /roles (roles-plataforma:ver), /organizations/:id/members (usuarios-tenant:ver), /organizations/:id/roles (roles-tenant:ver) — estas dos últimas son rutas anidadas, no ítems del nav: se llega por drill-down desde una fila de /organizations, nunca desde el menú.

    /users (listado cross-tenant de solo lectura) se quitó del nav y del router (2026-07-23, tarea platform-limpieza): no llevaba a ninguna acción. El endpoint GET /platform/users y la feature (src/features/users/) se conservan en disco como base de un futuro buscador global de soporte — ver backlog.

Sesión

Igual que el admin (ADR-0014): access token solo en memoria (Zustand, sin persist), refresh en cookie httpOnly (platform_refresh_token, path /auth/platform). Silent refresh al recargar vía /auth/platform/refresh, que devuelve user además del token — hidrata la sesión sin una llamada a /me aparte. El store también guarda permissions: string[] (persistido junto a user, igual que el admin) — llega en el mismo payload de signin/refresh/me, no requiere una llamada aparte.

Gate mustChangePassword (UI)

ProtectedRoute redirige a /change-password si user.mustChangePassword. Es UX: la garantía real es el PlatformPasswordChangeGuard del backend (arriba). Tras cambiar la clave, el store limpia el flag localmente (sin round-trip) y libera la navegación.

Gate por permiso (UI) — espejo del admin

ProtectedRoute acepta module/action opcionales: si el usuario no tiene ese permiso, redirige a / en vez de a /login (la sesión es válida, solo falta el permiso). El nav (AppLayout) filtra sus ítems con el mismo usePermission().can(module, action) — un operador sin roles-plataforma:ver no ve el ítem "Roles" en absoluto, no solo le fallaría la ruta.

Caso límite: rol sin ningún permiso. La home (/) es la única ruta que deliberadamente no lleva gate de permiso — si lo llevara y el rol no tuviera ningún permiso, ProtectedRoute redirigiría // en loop. En su lugar, DashboardPage misma chequea can('metricas', 'ver'): si no lo tiene, muestra "Tu rol no tiene permisos asignados. Contactá a un administrador de plataforma." en vez de intentar cargar métricas. Mismo patrón que el admin (/dashboard tampoco lleva ProtectedRoute con module/action, ver camaroneras_admin/src/app/router.tsx).

Pantallas v1

  • Dashboard (/): métricas globales, gate por permiso a nivel de contenido (arriba).

  • Organizaciones (/organizations): lista + activar/desactivar con diálogo de confirmación que advierte el bloqueo de usuarios. Diálogo "Nueva organización" (gateado por organizaciones:crear) con el control "¿Quién nombra la empresa?":

    • Yo (default): pide nombre de la empresa + email del admin + nombre del admin (opcional) → modo directo.
    • El cliente: oculta el campo de nombre, solo pide el email del admin (+ nombre opcional) → modo delegado; el name se omite del body por completo (no se manda vacío).

    Mientras el admin invitado no acepte, la fila muestra el badge "Admin invitado, no aceptó" y los botones "Reenviar invitación" y "Cancelar invitación" (ambos gateados por organizaciones:editar). "Cancelar invitación" pide confirmación (diálogo: nombra el email que se libera) y borra por completo al usuario invitado. Las invitaciones delegadas pendientes aparecen como filas propias al principio de la tabla: nombre en estado vacío ("Pendiente de nombrar"), sin slug ni acciones de activar/desactivar (no hay organización todavía), y sus botones de reenviar/cancelar pegan al endpoint de invitaciones (/platform/invitations/:userId/resend y .../cancel) en vez del de organizaciones. Al completar el cliente su onboarding, la fila desaparece y la organización real aparece en el siguiente refetch.

    Cada fila de una organización real trae dos links de drill-down, "Miembros" y "Roles" (gateados por usuarios-tenant:ver/roles-tenant:ver respectivamente; ocultos en las filas pending_invitation, que no tienen organización todavía):

    • Miembros (/organizations/:id/members, OrganizationMembersPage): mismo formulario/tabla que tenía camaroneras_admin/src/features/users (invitar por email+rol, cambiar rol inline, activar/desactivar, cancelar/reenviar invitación — desactivar y cancelar invitación piden confirmación en un diálogo, mismo patrón que OrganizationsPage), ahora operando sobre platform/organizations/:orgId/users en vez de /users. El nombre de la organización en el header viaja por state del Link al navegar desde /organizations (no hay GET /platform/organizations/:id); si se entra por URL directa o refresh (sin state), se muestra el placeholder "esta organización" en vez del id crudo.
    • Roles (/organizations/:id/roles, OrganizationRolesPage + OrganizationPermissionMatrixDialog): mismo patrón que tenía camaroneras_admin/src/features/roles (CRUD de roles + matriz módulo×acción), consumiendo GET platform/tenant-catalog/modules para el catálogo. Comparte el componente PermissionMatrixDialog genérico (shared/components/) con la matriz de roles de plataforma (abajo): el rol "por defecto" del tenant queda con la misma protección de solo-lectura (checkboxes deshabilitados, sin botón "Guardar", aviso "Rol por defecto: sus permisos no se pueden editar.") que Owner/Operator tienen en plataforma — antes solo ocultaba el botón "Eliminar", sin bloquear la edición de permisos (corregido en platform-code-review-fixes, 2026-07-23).
  • Operadores (/operators): gestión de quién tiene acceso a este panel — GET /platform/users?platformOnly=true. Crear (email + nombre opcional + rol + contraseña temporal — no es invitación por email, hay que comunicar la clave por otro medio), cambiar rol (select inline, deshabilitado en la fila propia — la invariante 2 del backend lo rechazaría con 403 igual, pero se evita el intento), desactivar/reactivar.

  • Roles (/roles): CRUD + matriz de permisos módulo×acción (PermissionMatrixDialog, portado de camaroneras_admin/src/features/roles). Roles de sistema (Owner/Operator) muestran sus permisos pero la matriz queda de solo lectura (checkboxes deshabilitados, sin botón "Guardar") — refleja la invariante 3 del backend en vez de dejar que el usuario intente guardar y reciba un 403.

Deliberadamente distinto del admin: no hay flujo de invitación por email para operadores (el admin sí lo tiene para usuarios de tenant) — el backend pide initialPassword directamente, mismo patrón que el bootstrap del primer owner.

Fuera de alcance (v1 del panel camaroneras_platform)

Acciones sensibles (impersonar, suspender con efectos, billing) y MFA/IP allowlist/auditoría (hardening futuro).

El signup público del admin (POST /auth/signup/auth/onboarding) coexistió con el alta desde plataforma hasta deprecar-signup-publico, que lo retiró — ver ADR-0050. Toda cuenta nueva entra por invitación (de tenant o de plataforma).