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/accountpage 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 declaresAdminLoginResponse.successas 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. NowgenerateTokenWithSessionmints 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 separateJWT_REFRESH_SECRET) - theadmin_sessionrow'sexpiresAtnow 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'srefresh_token_hashcolumn - 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 asadminResetPassword. Rate-limited via a newRATE_LIMITS.REFRESH_TOKENbucket (20/15min by default). On the frontend,apollo/client.ts'srefreshAdminSession()is called two ways: reactively, by an Apollo error link that catches anUNAUTHENTICATEDresponse, refreshes once, and retries the failed request; and proactively, by a timer inAdminAuthContextthat fires ~60s before the current access token's ownexpclaim, 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. viaadminRevokeSession/adminRevokeAllSessions) logs the admin out and redirects to/login?reason=expired, which shows a "session expired" notice.adminLogin/adminVerify2FA/adminVerifyBackupCodeall now also return arefreshTokenfield alongsidetoken. - Update own profile (
adminUpdateProfile- wired to the/accountpage's profile section) - Change own password (
adminChangePassword- wired to the/accountpage's password section) - Request a password reset email (
adminRequestPasswordReset- unauthenticated, always reports success to avoid email enumeration; wired to the/forgot-passwordpage, linked from the login page) - Reset password with a token (
adminResetPassword- unauthenticated; wired to the/reset-passwordpage)
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/accountpage'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 aBoolean!field, which graphql-js rejects at runtime; now returns the raw boolean. Wired to the/accountpage.) - Disable 2FA (
adminDisable2FA- fixed this session, same return-shape bug. Wired to the/accountpage.) - Regenerate backup codes (
adminRegenerateBackupCodes- fixed this session: resolver returned{ backupCodes }instead of the bare array the schema expects. Wired to the/accountpage.)
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/accountpage's Passkeys section (name, device type, backed-up, created/last-used). - Register a passkey (
adminPasskeyRegistrationOptions→adminVerifyPasskeyRegistration) — 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 (
adminPasskeyAuthenticationOptions→adminPasskeyLogin) — the login page offers a passkey option;adminPasskeyLoginreturns an admin session token on success. An optionalidentifierscopes the allowed credentials.- Fixed this session:
admin-passkey.service.js#verifyAuthenticationused to mint its token via the legacygenerateToken()(noadmin_sessionrow), notgenerateTokenWithSession()likeadminLogin/adminVerify2FA/adminVerifyBackupCodedo - every other admin request re-validates the token against a session row (seeadmin-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 arefreshToken. Now callsgenerateTokenWithSession()and returns arefreshToken, same as every other login path.
- Fixed this session:
- Types/resolvers:
graphql/types/admin/admin-passkey.type.js,graphql/resolvers/admin/admin-passkey.resolver.js; QR/registration UI on the frontend-admin/accountpage and the passkey button on the admin login page. - Deployment note:
admin-passkey.service.jsshares the sameWEBAUTHN_ORIGIN/WEBAUTHN_RP_IDenv vars as the main app's passkeys (services/passkey.service.js) — it does not have its own.WEBAUTHN_ORIGINis 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 withUnexpected registration response origin.WEBAUTHN_RP_IDdoes 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 theWEBAUTHN_*comment block inapps/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/accountpage's sessions section. Itsipfield is fixed this session:getSessionsWithDeviceInfo/getActiveSessionsbuilt the returned objects with anipAddresskey, but the schema field isip- it was always coming back null.) - Revoke a specific own session (
adminRevokeSession- fixed this session: resolver returned{ success: result }for aBoolean!field. Wired to the/accountpage.) - Revoke all own sessions (logout everywhere) (
adminRevokeAllSessions- fixed this session, twice: the resolver originally returned{ count }for aBoolean!field; that first fix incorrectly assumed the manager returns a Sequelize bulk-update tuple and destructured it asconst [affectedCount] = await ..., butrevokeAllSessions()already unwraps that tuple and returns a plain Number, so the destructuring threwTypeError: ... is not iterableon every call. Now reads the Number directly. Wired to the/accountpage.)
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/adminslist still usesadminUsersdirectly rather than fetching one at a time, but this doc previously said no frontend-admin UI existed foradminUserat all - corrected.) - List all admins (
adminUsers- wired to the/adminspage) - Register a new admin (
adminRegister- wired to the/adminspage's "Nuevo administrador" modal) - Deactivate an admin (
adminDeactivate- wired to the/adminspage's per-row toggle) - Activate an admin (
adminActivate- fixed this session: resolver returned{ success: result }for aBoolean!field. Wired to the/adminspage'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/adminslist itself), gated behind aConfirmDeleteModalthat 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'sAdminBulkOperationResponse{success: Boolean!, message, successCount, failedCount, total, errors}shape. Wired to the/adminspage's bulk action bar.) - Bulk activate admins (
adminBulkActivate- fixed this session, same return-shape bug asadminBulkDeactivate. Wired to the/adminspage'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 fromadminMe/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 viahasPermission(adminId, 'X')across every gated resolver and viaadmin?.permissions?.Xclient-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 withlabel/grantedfields, 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 bytests/unit-test/admin-permissions-catalog.unit.test.js. Wired to a "Permisos" action per row on the/adminspage.) - Admin statistics (super_admin only) (
adminStats- fixed this session:getAdminStats()returned{byRole, byStatus, total}, none of which match the schema'sAdminStats{totalAdmins, activeAdmins, inactiveAdmins, adminsByRole}field names - every field came back null. Wired to the/adminspage's stat cards.) - Update role and permissions (
adminUpdateRoleAndPermissions- wired to the/adminspage'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.permissionsreplaces 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 asadminBulkDeactivate/adminBulkActivate. UI added this session: aBulkPermissionsModaloff the/adminspage'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.