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/accounty 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 declaraAdminLoginResponse.successcomo 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. AhoragenerateTokenWithSessiongenera 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 elJWT_REFRESH_SECRETseparado) - el campoexpiresAtde la filaadmin_sessionahora 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 columnarefresh_token_hashde 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 queadminResetPassword. Limitado por tasa mediante un nuevo bucketRATE_LIMITS.REFRESH_TOKEN(20/15min por defecto). En el frontend,refreshAdminSession()deapollo/client.tsse invoca de dos formas: reactivamente, mediante un Apollo error link que detecta una respuestaUNAUTHENTICATED, refresca una vez y reintenta la petición fallida; y proactivamente, mediante un temporizador enAdminAuthContextque se dispara ~60s antes de que expire elexpdel 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íaadminRevokeSession/adminRevokeAllSessions) cierra la sesión del administrador y redirige a/login?reason=expired, que muestra un aviso de "sesión expirada".adminLogin/adminVerify2FA/adminVerifyBackupCodeahora también devuelven un camporefreshTokenjunto atoken. - 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 campoBoolean!, 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 (
adminPasskeyRegistrationOptions→adminVerifyPasskeyRegistration) — 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 (
adminPasskeyAuthenticationOptions→adminPasskeyLogin) — la página de login ofrece una opción de passkey;adminPasskeyLogindevuelve un token de sesión de administrador al tener éxito. Unidentifieropcional acota las credenciales permitidas.- Corregido en esta sesión:
admin-passkey.service.js#verifyAuthenticationantes generaba su token mediante elgenerateToken()heredado (sin filaadmin_session), no mediantegenerateTokenWithSession()como sí hacenadminLogin/adminVerify2FA/adminVerifyBackupCode- cada otra petición de administrador revalida el token contra una fila de sesión (veradmin-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 unrefreshToken. Ahora llama agenerateTokenWithSession()y devuelve unrefreshToken, igual que cualquier otro flujo de login.
- Corregido en esta sesión:
- Tipos/resolvers:
graphql/types/admin/admin-passkey.type.js,graphql/resolvers/admin/admin-passkey.resolver.js; interfaz de QR/registro en la página/accountdel frontend-admin y el botón de passkey en la página de login de administrador. - Nota de despliegue:
admin-passkey.service.jscomparte las mismas variables de entornoWEBAUTHN_ORIGIN/WEBAUTHN_RP_IDque las passkeys de la app principal (services/passkey.service.js) — no tiene las suyas propias.WEBAUTHN_ORIGINes 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 conUnexpected registration response origin.WEBAUTHN_RP_IDno 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 comentariosWEBAUTHN_*enapps/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 campoipfue corregido en esta sesión:getSessionsWithDeviceInfo/getActiveSessionsconstruían los objetos devueltos con una claveipAddress, pero el campo del esquema esip- 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 campoBoolean!. 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 campoBoolean!; esa primera corrección asumió incorrectamente que el manager devuelve una tupla de actualización masiva de Sequelize y la desestructuró comoconst [affectedCount] = await ..., perorevokeAllSessions()ya desempaqueta esa tupla y devuelve un Number simple, así que la desestructuración lanzabaTypeError: ... is not iterableen 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/adminssigue usandoadminUsersdirectamente en lugar de obtenerlos uno por uno, pero este documento decía antes que no existía ninguna interfaz en frontend-admin paraadminUser- 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 campoBoolean!. 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/adminsen sí), protegida por unConfirmDeleteModalque 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 formaAdminBulkOperationResponse{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 queadminBulkDeactivate. 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é desdeadminMe/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 mediantehasPermission(adminId, 'X')en cada resolver protegido y medianteadmin?.permissions?.Xdel 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 camposlabel/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 portests/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 campoAdminStats{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.permissionsreemplaza 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 queadminBulkDeactivate/adminBulkActivate. Interfaz agregada en esta sesión: unBulkPermissionsModalen la barra de acciones en lote de la página/adminsaplica 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.