Skip to main content

Admin Accounts

Admin accounts are self-managed through the admin schema — profile, login, 2FA, own sessions, and (for super_admin) creating and managing other admin accounts. This is now fully wired to the frontend-admin app: the login page, an "Mi cuenta" page (/account), and a super_admin-only "Administradores" page (/admins).

Implementation checklist

Own profile and login

  • Get current admin's own profile (adminMe - wired to the /account page and to the sidebar's admin name/role display)
  • Admin login (adminLogin - wired to the login page. Its 2FA-required branch is fixed this session: the schema declares AdminLoginResponse.success as non-null, but that branch never set it - every 2FA-enabled admin's first login step errored on a null-on-non-null GraphQL violation before they ever reached the code-entry step.)
  • Silent session renewal (adminRefreshToken - added this session). Previously the admin access token was a flat 8h JWT with no way to renew it short of logging in again, so every session died exactly 8 hours after login with no warning. Now generateTokenWithSession mints a short-lived access token (JWT_ADMIN_ACCESS_TOKEN_EXPIRY_MS, default 30 minutes) alongside a long-lived, rotating refresh token (JWT_ADMIN_REFRESH_TOKEN_EXPIRY_MS, default 30 days, signed with the separate JWT_REFRESH_SECRET) - the admin_session row's expiresAt now tracks the refresh token's outer bound rather than the access token's. adminRefreshToken(refreshToken) exchanges a valid refresh token for a brand-new access+refresh pair, rotating on every call (the old refresh token stops working the instant a new one is issued, via the session row's refresh_token_hash column - which existed unused since an earlier migration). It never throws GraphQL errors - a revoked/expired/malformed refresh token just gets {success: false, message} back, same convention as adminResetPassword. Rate-limited via a new RATE_LIMITS.REFRESH_TOKEN bucket (20/15min by default). On the frontend, apollo/client.ts's refreshAdminSession() is called two ways: reactively, by an Apollo error link that catches an UNAUTHENTICATED response, refreshes once, and retries the failed request; and proactively, by a timer in AdminAuthContext that fires ~60s before the current access token's own exp claim, so an actively-used admin panel never actually surfaces a 401 to begin with. A refresh that fails outright (session revoked server-side, e.g. via adminRevokeSession/adminRevokeAllSessions) logs the admin out and redirects to /login?reason=expired, which shows a "session expired" notice. adminLogin/adminVerify2FA/adminVerifyBackupCode all now also return a refreshToken field alongside token.
  • Update own profile (adminUpdateProfile - wired to the /account page's profile section)
  • Change own password (adminChangePassword - wired to the /account page's password section)
  • Request a password reset email (adminRequestPasswordReset - unauthenticated, always reports success to avoid email enumeration; wired to the /forgot-password page, linked from the login page)
  • Reset password with a token (adminResetPassword - unauthenticated; wired to the /reset-password page)

Admin 2FA

  • Verify 2FA code during login (adminVerify2FA - wired to the login page)
  • Verify backup code during login (adminVerifyBackupCode - wired to a "use a backup code instead" toggle on the login page's 2FA step, which swaps the TOTP code input for a backup-code input; this doc previously said the step only accepted a TOTP code - corrected)
  • Enable 2FA / get QR code and secret (adminEnable2FA - wired to the /account page's 2FA section, shows the QR code and one-time backup codes)
  • Confirm 2FA setup (adminConfirm2FA - fixed this session: the resolver returned { success: result } for a Boolean! field, which graphql-js rejects at runtime; now returns the raw boolean. Wired to the /account page.)
  • Disable 2FA (adminDisable2FA - fixed this session, same return-shape bug. Wired to the /account page.)
  • Regenerate backup codes (adminRegenerateBackupCodes - fixed this session: resolver returned { backupCodes } instead of the bare array the schema expects. Wired to the /account page.)

Admin passkeys (WebAuthn)

Passwordless sign-in for the admin panel, mirroring the main app's passkeys. Credentials are per-admin and stored via the admin passkey service.

  • List the admin's registered passkeys (adminPasskeys) — shown on the /account page's Passkeys section (name, device type, backed-up, created/last-used).
  • Register a passkey (adminPasskeyRegistrationOptionsadminVerifyPasskeyRegistration) — the browser runs the creation ceremony; the verified credential is saved with an optional friendly name.
  • Delete a passkey (adminDeletePasskey).
  • Log in with a passkey (adminPasskeyAuthenticationOptionsadminPasskeyLogin) — the login page offers a passkey option; adminPasskeyLogin returns an admin session token on success. An optional identifier scopes the allowed credentials.
    • Fixed this session: admin-passkey.service.js#verifyAuthentication used to mint its token via the legacy generateToken() (no admin_session row), not generateTokenWithSession() like adminLogin/adminVerify2FA/adminVerifyBackupCode do - every other admin request re-validates the token against a session row (see admin-auth-helper.js), so a passkey login used to get rejected as "no session found" on its very next request, and since it never had a session row it also never got a refreshToken. Now calls generateTokenWithSession() and returns a refreshToken, same as every other login path.
  • Types/resolvers: graphql/types/admin/admin-passkey.type.js, graphql/resolvers/admin/admin-passkey.resolver.js; QR/registration UI on the frontend-admin /account page and the passkey button on the admin login page.
  • Deployment note: admin-passkey.service.js shares the same WEBAUTHN_ORIGIN/WEBAUTHN_RP_ID env vars as the main app's passkeys (services/passkey.service.js) — it does not have its own. WEBAUTHN_ORIGIN is a comma-separated list and must include the admin panel's own origin (e.g. https://admin.closegram.com) in addition to the client app's, or registration fails with Unexpected registration response origin. WEBAUTHN_RP_ID does not need a separate admin entry — set it to the shared parent domain (e.g. closegram.com), which WebAuthn already treats as valid for any subdomain, including the admin panel. See the WEBAUTHN_* comment block in apps/backend/.env.example.

Own sessions

These operate on the calling admin's own login sessions (via admin.adminId from the JWT) — not on regular users' or other admins' sessions.

  • List own active login sessions (adminActiveSessions - wired to the /account page's sessions section. Its ip field is fixed this session: getSessionsWithDeviceInfo/getActiveSessions built the returned objects with an ipAddress key, but the schema field is ip - it was always coming back null.)
  • Revoke a specific own session (adminRevokeSession - fixed this session: resolver returned { success: result } for a Boolean! field. Wired to the /account page.)
  • Revoke all own sessions (logout everywhere) (adminRevokeAllSessions - fixed this session, twice: the resolver originally returned { count } for a Boolean! field; that first fix incorrectly assumed the manager returns a Sequelize bulk-update tuple and destructured it as const [affectedCount] = await ..., but revokeAllSessions() already unwraps that tuple and returns a plain Number, so the destructuring threw TypeError: ... is not iterable on every call. Now reads the Number directly. Wired to the /account page.)

Managing other admins

Super_admin only. Wired to the /admins page, which is hidden from the nav and access-gated for non-super_admin roles.

  • Get an admin by ID (adminUser - UI added this session: a real detail page at /admins/[id] now queries it directly and renders username, email, role, 2FA status, force-password-change flag, last login and created-at, with per-admin activate/deactivate, permissions and delete actions. The /admins list still uses adminUsers directly rather than fetching one at a time, but this doc previously said no frontend-admin UI existed for adminUser at all - corrected.)
  • List all admins (adminUsers - wired to the /admins page)
  • Register a new admin (adminRegister - wired to the /admins page's "Nuevo administrador" modal)
  • Deactivate an admin (adminDeactivate - wired to the /admins page's per-row toggle)
  • Activate an admin (adminActivate - fixed this session: resolver returned { success: result } for a Boolean! field. Wired to the /admins page's per-row toggle.)
  • Delete an admin permanently (adminDelete - UI added this session: a delete action now lives on the new /admins/[id] detail page (not the /admins list itself), gated behind a ConfirmDeleteModal that requires typing the target admin's username before the delete goes through, plus an optional reason passed to the mutation. This doc previously said no delete UI existed anywhere in the admin panel - corrected.)
  • Bulk deactivate admins (adminBulkDeactivate - fixed this session: the manager returned its internal {success: [ids], failed: [...], total} tracking object directly instead of the schema's AdminBulkOperationResponse{success: Boolean!, message, successCount, failedCount, total, errors} shape. Wired to the /admins page's bulk action bar.)
  • Bulk activate admins (adminBulkActivate - fixed this session, same return-shape bug as adminBulkDeactivate. Wired to the /admins page's bulk action bar.)

Permissions and roles

  • Check a permission (adminHasPermission - backend ready; deliberately has no dedicated UI. It checks the calling admin's own permissions, which the frontend already has cached from adminMe/login (AdminAuthContext), so a round-trip to ask "do I have X" would be redundant with data already in hand.)
  • Get detailed permissions (adminPermissions(adminId) - fixed this session, two bugs: (1) the manager's permission catalog was entirely fictional - users/posts/comments/reports/analytics/settings/admins/billing (lowercase), with no relationship whatsoever to the permission strings actually enforced anywhere in the codebase (VIEW_USERS/SUSPEND_USERS/WARN_USERS/BAN_USERS/VERIFY_USERS/MODERATE_CONTENT/REMOVE_CONTENT/VIEW_ANALYTICS/EXPORT_DATA/MANAGE_PAYOUTS/MANAGE_PROMOTIONS, checked via hasPermission(adminId, 'X') across every gated resolver and via admin?.permissions?.X client-side) - so every non-super_admin was always reported as lacking all 8 fake permissions, and there was no way to see or grant any of the real ones short of hand-editing the DB. (2) The manager also returned a permission-keyed object wrapped in an {adminId, username, email, role, permissions} envelope with label/granted fields, none of which match the schema's flat [AdminPermissionDetails!]! = {permission, description, category, hasPermission}[] - calling this query failed non-null-list resolution regardless of the catalog. Both fixed; covered by tests/unit-test/admin-permissions-catalog.unit.test.js. Wired to a "Permisos" action per row on the /admins page.)
  • Admin statistics (super_admin only) (adminStats - fixed this session: getAdminStats() returned {byRole, byStatus, total}, none of which match the schema's AdminStats{totalAdmins, activeAdmins, inactiveAdmins, adminsByRole} field names - every field came back null. Wired to the /admins page's stat cards.)
  • Update role and permissions (adminUpdateRoleAndPermissions - wired to the /admins page's per-row role selector (role-only), and now also to the new "Permisos" modal, which submits the full permission set on every save - AdminRoleUpdateInput.permissions replaces the stored JSON blob rather than merging into it, so a partial payload would silently wipe out untouched grants.)
  • Bulk update permissions (adminBulkUpdatePermissions - fixed this session, same return-shape bug as adminBulkDeactivate/adminBulkActivate. UI added this session: a BulkPermissionsModal off the /admins page's bulk action bar applies one permission set to every selected admin at once - unlike the per-admin editor it doesn't seed from any target's current grants (the selected admins may differ from each other), so every permission starts unchecked and whatever's checked becomes the exact stored set for all selected admins on save. This doc previously said the permissions editor was per-admin only with no bulk variant - corrected.)

Technical reference

See Admin Panel → Admin account management for the full GraphQL API.