Saltar al contenido principal

Cuentas de administrador

Las cuentas de administrador se autogestionan a través del esquema de administrador — perfil, inicio de sesión, 2FA, sesiones propias y (para super_admin) la creación y gestión de otras cuentas de administrador. Esto ya está completamente conectado a la app frontend-admin: la página de inicio de sesión, una página "Mi cuenta" (/account), y una página "Administradores" (/admins) exclusiva para super_admin.

Checklist de implementación

Perfil propio e inicio de sesión

  • Obtener el perfil propio del administrador actual (adminMe - conectado a la página /account y al indicador de nombre/rol del administrador en la barra lateral)
  • Inicio de sesión de administrador (adminLogin - conectado a la página de login. Su rama de 2FA obligatorio fue corregida en esta sesión: el esquema declara AdminLoginResponse.success como no-nulo, pero esa rama nunca lo asignaba - el primer paso de inicio de sesión de cada administrador con 2FA activado fallaba con una violación de GraphQL de valor nulo en un campo no-nulo, antes incluso de llegar al paso de ingresar el código.)
  • Renovación silenciosa de sesión (adminRefreshToken - agregado en esta sesión). Antes el token de acceso de administrador era un JWT plano de 8h sin forma de renovarlo salvo iniciar sesión de nuevo, así que toda sesión moría exactamente 8 horas después del login sin ningún aviso. Ahora generateTokenWithSession genera un token de acceso de vida corta (JWT_ADMIN_ACCESS_TOKEN_EXPIRY_MS, 30 minutos por defecto) junto con un refresh token de vida larga y rotativo (JWT_ADMIN_REFRESH_TOKEN_EXPIRY_MS, 30 días por defecto, firmado con el JWT_REFRESH_SECRET separado) - el campo expiresAt de la fila admin_session ahora refleja el límite externo del refresh token en lugar del del token de acceso. adminRefreshToken(refreshToken) intercambia un refresh token válido por un par nuevo de acceso+refresh, rotando en cada llamada (el refresh token anterior deja de funcionar en cuanto se emite uno nuevo, mediante la columna refresh_token_hash de la fila de sesión - que existía sin usarse desde una migración anterior). Nunca lanza errores de GraphQL - un refresh token revocado, expirado o malformado simplemente devuelve {success: false, message}, la misma convención que adminResetPassword. Limitado por tasa mediante un nuevo bucket RATE_LIMITS.REFRESH_TOKEN (20/15min por defecto). En el frontend, refreshAdminSession() de apollo/client.ts se invoca de dos formas: reactivamente, mediante un Apollo error link que detecta una respuesta UNAUTHENTICATED, refresca una vez y reintenta la petición fallida; y proactivamente, mediante un temporizador en AdminAuthContext que se dispara ~60s antes de que expire el exp del token de acceso actual, de modo que un panel de administración activamente en uso nunca llega a mostrar un 401. Un refresh que falla definitivamente (sesión revocada del lado del servidor, p. ej. vía adminRevokeSession/adminRevokeAllSessions) cierra la sesión del administrador y redirige a /login?reason=expired, que muestra un aviso de "sesión expirada". adminLogin/adminVerify2FA/adminVerifyBackupCode ahora también devuelven un campo refreshToken junto a token.
  • Actualizar el perfil propio (adminUpdateProfile - conectado a la sección de perfil de la página /account)
  • Cambiar la contraseña propia (adminChangePassword - conectado a la sección de contraseña de la página /account)
  • Solicitar un correo de restablecimiento de contraseña (adminRequestPasswordReset - sin autenticación, siempre reporta éxito para evitar la enumeración de correos; conectado a la página /forgot-password, enlazada desde la página de login)
  • Restablecer la contraseña con un token (adminResetPassword - sin autenticación; conectado a la página /reset-password)

2FA de administrador

  • Verificar código 2FA durante el inicio de sesión (adminVerify2FA - conectado a la página de login)
  • Verificar código de respaldo durante el inicio de sesión (adminVerifyBackupCode - conectado a un enlace "usar un código de respaldo" en el paso de 2FA de la página de login, que intercambia el input de código TOTP por un input de código de respaldo; este documento decía antes que el paso solo aceptaba un código TOTP — corregido)
  • Activar 2FA / obtener código QR y secreto (adminEnable2FA - conectado a la sección de 2FA de la página /account, muestra el código QR y los códigos de respaldo de un solo uso)
  • Confirmar la configuración de 2FA (adminConfirm2FA - corregido en esta sesión: el resolver devolvía { success: result } para un campo Boolean!, lo cual graphql-js rechaza en tiempo de ejecución; ahora devuelve el booleano crudo. Conectado a la página /account.)
  • Desactivar 2FA (adminDisable2FA - corregido en esta sesión, mismo bug de forma de retorno. Conectado a la página /account.)
  • Regenerar códigos de respaldo (adminRegenerateBackupCodes - corregido en esta sesión: el resolver devolvía { backupCodes } en lugar del arreglo simple que espera el esquema. Conectado a la página /account.)

Passkeys de administrador (WebAuthn)

Inicio de sesión sin contraseña para el panel de administración, siguiendo el mismo esquema que las passkeys de la app principal. Las credenciales son propias de cada administrador y se almacenan mediante el servicio de passkeys de administrador.

  • Listar las passkeys registradas del administrador (adminPasskeys) — se muestran en la sección de Passkeys de la página /account (nombre, tipo de dispositivo, si está respaldada, creación/último uso).
  • Registrar una passkey (adminPasskeyRegistrationOptionsadminVerifyPasskeyRegistration) — el navegador ejecuta la ceremonia de creación; la credencial verificada se guarda con un nombre amigable opcional.
  • Eliminar una passkey (adminDeletePasskey).
  • Iniciar sesión con una passkey (adminPasskeyAuthenticationOptionsadminPasskeyLogin) — la página de login ofrece una opción de passkey; adminPasskeyLogin devuelve un token de sesión de administrador al tener éxito. Un identifier opcional acota las credenciales permitidas.
    • Corregido en esta sesión: admin-passkey.service.js#verifyAuthentication antes generaba su token mediante el generateToken() heredado (sin fila admin_session), no mediante generateTokenWithSession() como sí hacen adminLogin/adminVerify2FA/adminVerifyBackupCode - cada otra petición de administrador revalida el token contra una fila de sesión (ver admin-auth-helper.js), así que un inicio de sesión con passkey antes era rechazado como "sesión no encontrada" en su siguiente petición, y como nunca tenía fila de sesión tampoco obtenía nunca un refreshToken. Ahora llama a generateTokenWithSession() y devuelve un refreshToken, igual que cualquier otro flujo de login.
  • Tipos/resolvers: graphql/types/admin/admin-passkey.type.js, graphql/resolvers/admin/admin-passkey.resolver.js; interfaz de QR/registro en la página /account del frontend-admin y el botón de passkey en la página de login de administrador.
  • Nota de despliegue: admin-passkey.service.js comparte las mismas variables de entorno WEBAUTHN_ORIGIN/WEBAUTHN_RP_ID que las passkeys de la app principal (services/passkey.service.js) — no tiene las suyas propias. WEBAUTHN_ORIGIN es una lista separada por comas y debe incluir el propio origen del panel de administración (p. ej. https://admin.closegram.com) además del de la app cliente, o el registro falla con Unexpected registration response origin. WEBAUTHN_RP_ID no necesita una entrada separada para administración — configúralo con el dominio padre compartido (p. ej. closegram.com), que WebAuthn ya considera válido para cualquier subdominio, incluido el panel de administración. Ver el bloque de comentarios WEBAUTHN_* en apps/backend/.env.example.

Sesiones propias

Estas operan sobre las sesiones de inicio de sesión propias del administrador que llama (mediante admin.adminId del JWT) — no sobre las de usuarios regulares ni las de otros administradores.

  • Listar sesiones activas propias (adminActiveSessions - conectado a la sección de sesiones de la página /account. Su campo ip fue corregido en esta sesión: getSessionsWithDeviceInfo/getActiveSessions construían los objetos devueltos con una clave ipAddress, pero el campo del esquema es ip - siempre volvía nulo.)
  • Revocar una sesión propia específica (adminRevokeSession - corregido en esta sesión: el resolver devolvía { success: result } para un campo Boolean!. Conectado a la página /account.)
  • Revocar todas las sesiones propias (cerrar sesión en todos lados) (adminRevokeAllSessions - corregido en esta sesión, dos veces: el resolver originalmente devolvía { count } para un campo Boolean!; esa primera corrección asumió incorrectamente que el manager devuelve una tupla de actualización masiva de Sequelize y la desestructuró como const [affectedCount] = await ..., pero revokeAllSessions() ya desempaqueta esa tupla y devuelve un Number simple, así que la desestructuración lanzaba TypeError: ... is not iterable en cada llamada. Ahora lee el Number directamente. Conectado a la página /account.)

Gestión de otros administradores

Solo para super_admin. Conectado a la página /admins, la cual está oculta de la navegación y restringida para roles distintos a super_admin.

  • Obtener un administrador por ID (adminUser - interfaz agregada en esta sesión: una página de detalle real en /admins/[id] ahora la consulta directamente y muestra nombre de usuario, correo, rol, estado de 2FA, indicador de cambio de contraseña forzado, último inicio de sesión y fecha de creación, con acciones de activar/desactivar, permisos y eliminar por administrador. La lista de /admins sigue usando adminUsers directamente en lugar de obtenerlos uno por uno, pero este documento decía antes que no existía ninguna interfaz en frontend-admin para adminUser - corregido.)
  • Listar todos los administradores (adminUsers - conectado a la página /admins)
  • Registrar un nuevo administrador (adminRegister - conectado al modal "Nuevo administrador" de la página /admins)
  • Desactivar un administrador (adminDeactivate - conectado al interruptor por fila de la página /admins)
  • Activar un administrador (adminActivate - corregido en esta sesión: el resolver devolvía { success: result } para un campo Boolean!. Conectado al interruptor por fila de la página /admins.)
  • Eliminar un administrador permanentemente (adminDelete - interfaz agregada en esta sesión: ahora hay una acción de eliminar en la nueva página de detalle /admins/[id] (no en la lista /admins en sí), protegida por un ConfirmDeleteModal que exige escribir el nombre de usuario del administrador objetivo antes de que la eliminación se ejecute, más un motivo opcional que se envía a la mutation. Este documento decía antes que no existía ninguna interfaz de eliminación en todo el panel de administración - corregido.)
  • Desactivar administradores en lote (adminBulkDeactivate - corregido en esta sesión: el manager devolvía directamente su objeto interno de seguimiento {success: [ids], failed: [...], total} en lugar de la forma AdminBulkOperationResponse{success: Boolean!, message, successCount, failedCount, total, errors} del esquema. Conectado a la barra de acciones en lote de la página /admins.)
  • Activar administradores en lote (adminBulkActivate - corregido en esta sesión, mismo bug de forma de retorno que adminBulkDeactivate. Conectado a la barra de acciones en lote de la página /admins.)

Permisos y roles

  • Verificar un permiso (adminHasPermission - backend listo; deliberadamente sin interfaz dedicada. Verifica los permisos propios del administrador que llama, los cuales el frontend ya tiene en caché desde adminMe/login (AdminAuthContext), así que un viaje de ida y vuelta para preguntar "¿tengo X?" sería redundante con datos que ya se tienen a mano.)
  • Obtener permisos detallados (adminPermissions(adminId) - corregido en esta sesión, dos bugs: (1) el catálogo de permisos del manager era completamente ficticio - users/posts/comments/reports/analytics/settings/admins/billing (minúsculas), sin ninguna relación con las cadenas de permisos realmente aplicadas en cualquier parte del código (VIEW_USERS/SUSPEND_USERS/WARN_USERS/BAN_USERS/VERIFY_USERS/MODERATE_CONTENT/REMOVE_CONTENT/VIEW_ANALYTICS/EXPORT_DATA/MANAGE_PAYOUTS/MANAGE_PROMOTIONS, verificadas mediante hasPermission(adminId, 'X') en cada resolver protegido y mediante admin?.permissions?.X del lado del cliente) - así que cada administrador no-super_admin siempre se reportaba careciendo de los 8 permisos ficticios, y no había forma de ver u otorgar ninguno de los reales salvo editando la BD a mano. (2) El manager además devolvía un objeto con clave de permiso envuelto en un sobre {adminId, username, email, role, permissions} con campos label/granted, ninguno de los cuales coincide con el [AdminPermissionDetails!]! = {permission, description, category, hasPermission}[] plano del esquema - llamar a esta query fallaba la resolución de la lista no-nula sin importar el arreglo del catálogo. Ambos corregidos; cubierto por tests/unit-test/admin-permissions-catalog.unit.test.js. Conectado a una acción "Permisos" por fila en la página /admins.)
  • Estadísticas de administradores (solo super_admin) (adminStats - corregido en esta sesión: getAdminStats() devolvía {byRole, byStatus, total}, ninguno de los cuales coincide con los nombres de campo AdminStats{totalAdmins, activeAdmins, inactiveAdmins, adminsByRole} del esquema - todos los campos volvían nulos. Conectado a las tarjetas de estadísticas de la página /admins.)
  • Actualizar rol y permisos (adminUpdateRoleAndPermissions - conectado al selector de rol por fila de la página /admins (solo rol), y ahora también al nuevo modal de "Permisos", el cual envía el conjunto completo de permisos en cada guardado - AdminRoleUpdateInput.permissions reemplaza el JSON almacenado en lugar de fusionarse con él, así que un payload parcial borraría silenciosamente los permisos otorgados que no se tocaron.)
  • Actualizar permisos en lote (adminBulkUpdatePermissions - corregido en esta sesión, mismo bug de forma de retorno que adminBulkDeactivate/adminBulkActivate. Interfaz agregada en esta sesión: un BulkPermissionsModal en la barra de acciones en lote de la página /admins aplica un mismo conjunto de permisos a todos los administradores seleccionados a la vez - a diferencia del editor por administrador, no parte de los permisos actuales de ningún objetivo (los administradores seleccionados pueden diferir entre sí), así que cada permiso empieza sin marcar y lo que quede marcado se convierte en el conjunto exacto almacenado para todos los seleccionados al guardar. Este documento decía antes que el editor de permisos era solo por administrador sin variante en lote - corregido.)

Referencia técnica

Ver Panel de administración → Gestión de cuentas de administrador para la API completa de GraphQL.