Apariencia
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
Userpuede tenerplatformRoleId→PlatformRole(tabla propia, ver "RBAC de plataforma" abajo).null= usuario normal de tenant. Un usuario de plataforma no necesitaOrganizationUser. platformActive(bool, defaulttrue): desactivación propia de plataforma, independiente destatus(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 ymustChangePassword=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) yOperator(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, peroOwnerlo tiene igual queAdministradortiene 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ódulo | Acciones | Por qué |
|---|---|---|
organizaciones | ver, editar | puede corregir/activar-desactivar datos de una org |
usuarios-tenant | ver, crear, editar | altas/ediciones de usuarios de un tenant |
roles-tenant | ver | solo lectura de roles de tenant |
usuarios-plataforma | ver | no crea usuarios de plataforma (cierra la escalada) |
roles-plataforma | ver | no amplía roles de plataforma, ni el propio (misma razón) |
metricas | ver | dashboard |
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
- 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 quitarusuarios-plataforma:creara un rol víaPUT /platform/roles/:id/permissionssi 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. - No auto-escalada. Un usuario no puede cambiar su propio rol (
PATCH /platform/users/:id/roleconid= el propio) →403. Tampoco puede editar los permisos del rol que él mismo tiene (PUT /platform/roles/:id/permissions) →403. - Rol de sistema intocable.
Owner/Operatorno se renombran, no se les edita la matriz de permisos, no se eliminan →403. - 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.
- Aislamiento de scope (ver sección de tokens abajo): un token de tenant nunca entra a
/platform/*, y viceversa. - Desactivación con efecto inmediato.
platformActive=falselo chequeaPlatformPasswordChangeGuarden cada request a/platform/*(lookup en BD, mismo guard que ya chequeabamustChangePassword) — no solo ensignin/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
| Token | Scope | Secret | Duración (env) | Sirve para |
|---|---|---|---|---|
| platform access | platform | JWT_ACCESS_SECRET | 15m | Superficie de plataforma (sin organizationId) |
| platform refresh | — | JWT_REFRESH_SECRET | 7d | Renovar (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.totalyrecentSignups.countcuentan 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 dePOSTabajo). Cada item traeisActive, nº de usuarios, nº de piscinas, ypendingAdminInvitation: {email, expiresAt} | null. En una filapending_invitation,ides el del usuario invitado (no hay organización),name/slugsonnull, ypendingAdminInvitationnunca esnull. Las filas delegadas se devuelven siempre completas y al principio, sin paginar — son pocas y transitorias;meta.total/meta.pageSizedescriben únicamente las organizaciones reales, así que el cliente no debe asumirdata.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, elsignin/refreshde 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)namees opcional — define el modo:- Directo (
namepresente): 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 quePOST /auth/onboarding, verplataforma/rbac-flow.md), y unUserconstatus: invitedya miembro como primer Administrador. Respuesta:organization: {id, name, slug}. - Delegado (
nameausente): crea solo elUserinvitado — 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 — verplataforma/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 conPOST /auth/accept-invitation, que bifurca según si ya hay organización o no (verplataforma/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 }- Directo (
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:idde org para identificar al invitado, así que se busca poruserId(el mismoidque trae la filapending_invitationdel 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) — verplataforma/admin-ui-flow.mdpara 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 conisSystem,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.403siisSystem.DELETE /platform/roles/:id— (roles-plataforma:eliminar)403siisSystem;400si 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.403siisSystemo si el rol es el propio del caller (invariante 2).400si algún parmodule:actionno 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=truefiltra 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.403siides el propio usuario (invariante 2).400si 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 poneplatformActive=false.400si es el último manager (invariante 1) — a diferencia del rol, sí 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 queplataforma/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 (tablasModule/Action— el mismo que usaGET /catalog/modulesdel lado tenant, verplataforma/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, tareaplatform-limpieza): no llevaba a ninguna acción. El endpointGET /platform/usersy 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 pororganizaciones: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
namese 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/resendy.../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:verrespectivamente; ocultos en las filaspending_invitation, que no tienen organización todavía):- Miembros (
/organizations/:id/members,OrganizationMembersPage): mismo formulario/tabla que teníacamaroneras_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 queOrganizationsPage), ahora operando sobreplatform/organizations/:orgId/usersen vez de/users. El nombre de la organización en el header viaja porstatedelLinkal navegar desde/organizations(no hayGET /platform/organizations/:id); si se entra por URL directa o refresh (sinstate), se muestra el placeholder "esta organización" en vez delidcrudo. - Roles (
/organizations/:id/roles,OrganizationRolesPage+OrganizationPermissionMatrixDialog): mismo patrón que teníacamaroneras_admin/src/features/roles(CRUD de roles + matriz módulo×acción), consumiendoGET platform/tenant-catalog/modulespara el catálogo. Comparte el componentePermissionMatrixDialoggené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.") queOwner/Operatortienen en plataforma — antes solo ocultaba el botón "Eliminar", sin bloquear la edición de permisos (corregido enplatform-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 decamaroneras_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).