Admin Panel
Closegram includes an admin panel (frontend-admin) talking to its own GraphQL schema, for dashboards, user moderation, and content review.
Admin roles
| Role | Description |
|---|---|
moderator | Can review reports and moderate content |
admin | Moderator + user management |
super_admin | Full access including system settings and other admin accounts |
Admin accounts are stored separately from regular users (AdminUser model, not User). They support:
- 2FA (TOTP) — opt-in per admin account; can be made mandatory for
super_adminvia theADMIN_REQUIRE_2FA_SUPER_ADMINenv var (defaults to off) - IP whitelisting — an
AdminIPWhitelistmodel, access service, and manager, exposed viaadminIPWhitelist(query) andadminAddIPWhitelist/adminRemoveIPWhitelist/adminSetIPWhitelistActive(mutations), with a real add/remove/toggle-active UI at/ip-whitelist(this doc previously said it wasn't reachable from the admin panel — corrected) - Session tracking — each admin's own active login sessions, viewable/revocable via
adminActiveSessions/adminRevokeSession/adminRevokeAllSessions - Activity log —
adminGetActivityLogrecords admin actions
GraphQL endpoint
The admin panel (frontend-admin) talks to its own GraphQL endpoint, separate from the client-facing one:
POST /admin/graphql # http://localhost:8000/admin/graphql in dev
This is a genuinely separate schema (not just a separate route on the same schema) containing only the admin*-prefixed queries and mutations documented on this page — regular client operations aren't reachable here, and admin operations aren't reachable on /web/graphql (the client endpoint). See GraphQL & Apollo → Endpoints for details. frontend-admin points at this endpoint via NEXT_PUBLIC_GRAPHQL_URL in its .env.local.
Feature pages
Each area below has its own implementation-checklist page, mirroring the structure of the user-facing feature docs:
- Analytics Dashboard
- User Moderation
- Content Moderation
- Admin Accounts
- Payments & Payouts
- Identity Verification Review
- Shop Disputes
- Post Promotions Review
- Appeals Review
- Environment Status
- App Version Control
- User Feedback
The badge-verification admin review queue (/verification) is documented on the user-facing User Verification page's technical reference rather than as a separate admin doc, since it's mostly a thin wrapper around that feature's existing business logic.
The rest of this page is the technical GraphQL reference those checklists link into.
Granular analytics queries
There is no single composite "dashboard" query — each metric below is a separate GraphQL field. adminGetContentStats has a real resolver (admin/admin-dashboard.resolver.js → adminDashboardManager.getContentStats, gated on VIEW_ANALYTICS) returning real post/media/hashtag counts. adminGetSystemHealth also returns real values across the board — DB round-trip + Apollo-plugin average response time, Redis/storage status, OS memory/CPU/disk usage, and active DB-pool connections — not just databaseStatus.
# Platform-wide counters
query PlatformStats { adminGetPlatformStats {
totalUsers activeUsersToday activeUsersWeek activeUsersMonth
totalPosts postsToday totalComments commentsToday
totalConversations conversationsToday
totalReports pendingReports
totalRevenue revenueToday revenueMonth
}}
# User growth over time
query UserGrowth($period: String!, $startDate: DateTime, $endDate: DateTime) {
adminGetUserGrowth(period: $period, startDate: $startDate, endDate: $endDate) {
period totalGrowth growthRate
data { date newUsers activeUsers retentionRate }
}
}
# Engagement per day
query EngagementStats($period: String!, $contentType: String) {
adminGetEngagementStats(period: $period, contentType: $contentType) {
period averageEngagement engagementRate
data { date posts comments likes shares messages }
}
}
# Revenue breakdown
query RevenueStats($period: String!, $startDate: DateTime, $endDate: DateTime) {
adminGetRevenueStats(period: $period, startDate: $startDate, endDate: $endDate) {
period totalRevenue growthRate
data { date revenue transactions averageTransaction }
}
}
# Content creation stats by period
query ContentCreationStats($period: String!, $startDate: DateTime, $endDate: DateTime) {
adminGetContentCreationStats(period: $period, startDate: $startDate, endDate: $endDate) {
period data { date posts media }
}
}
# Moderation queue stats
query ModerationQueueStats { adminGetModerationQueueStats {
pendingReports urgentReports highPriorityReports normalPriorityReports
}}
# Top content and users
query TopContent($metric: String!, $contentType: String, $limit: Int, $period: String) {
adminGetTopContent(metric: $metric, contentType: $contentType, limit: $limit, period: $period) {
id type title metricValue author { username }
}
}
query TopUsers($metric: String!, $limit: Int, $period: String) {
adminGetTopUsers(metric: $metric, limit: $limit, period: $period) {
rank metricValue user { username }
}
}
# Subscription analytics
query SubscriptionStats($period: String!) {
adminGetSubscriptionStats(period: $period) {
totalSubscriptions newSubscriptions cancelledSubscriptions revenue growthRate
}
}
# System health - all fields return real values (DB, Redis/storage, OS memory/CPU/disk, DB pool connections, error rate)
query SystemHealth { adminGetSystemHealth {
cpuUsage memoryUsage diskUsage databaseStatus
errorRate requestsPerMinute avgResponseTime
}}
# Hashtag trends
query HashtagStats($limit: Int, $period: String) {
adminGetHashtagStats(limit: $limit, period: $period) { hashtag usageCount trendingScore recentPosts }
}
Analytics export
adminExportAnalytics builds real rows from the analytics methods (users/content/engagement/revenue/moderation), serializes to CSV or JSON, uploads to S3 (or writes a local file fallback), and returns a real downloadUrl + fileSize.
query AdminExportAnalytics($from: DateTime!, $to: DateTime!, $format: String) {
adminExportAnalytics(from: $from, to: $to, format: $format) {
downloadUrl generatedAt
}
}
query AdminActivityLog($limit: Int, $offset: Int, $adminId: ID) {
adminGetActivityLog(limit: $limit, offset: $offset, adminId: $adminId) {
id action targetType targetId performedAt
admin { id username }
}
}
User management
Search and view users
query AdminGetUsers($search: String, $status: String, $limit: Int, $offset: Int, $orderBy: String) {
adminGetUsers(search: $search, status: $status, limit: $limit, offset: $offset, orderBy: $orderBy) {
total limit offset
users { id status user { id username email } }
}
}
query AdminGetUserDetails($userId: ID!) { adminGetUserDetails(userId: $userId) {
id status suspensionCount warningCount
user { id username email accountStatus }
}}
query AdminGetUserModerationHistory($userId: ID!, $limit: Int, $offset: Int) {
adminGetUserModerationHistory(userId: $userId, limit: $limit, offset: $offset) {
id action reason performedAt admin { username }
}
}
query AdminGetSuspendedUsers($limit: Int, $offset: Int) { adminGetSuspendedUsers(limit: $limit, offset: $offset) { total users { id user { username } } } }
query AdminGetBannedUsers($limit: Int, $offset: Int) { adminGetBannedUsers(limit: $limit, offset: $offset) { total users { id user { username } } } }
query AdminGetUserStats($startDate: DateTime, $endDate: DateTime) { adminGetUserStats(startDate: $startDate, endDate: $endDate) { totalUsers suspendedUsers bannedUsers } }
Suspend, warn, and ban
mutation AdminSuspendUser($userId: ID!, $reason: String!, $durationDays: Int) {
adminSuspendUser(userId: $userId, reason: $reason, durationDays: $durationDays) { success }
}
mutation AdminWarnUser($userId: ID!, $reason: String!, $message: String) {
adminWarnUser(userId: $userId, reason: $reason, message: $message) { success }
}
mutation AdminBanUser($userId: ID!, $reason: String!, $permanent: Boolean) {
adminBanUser(userId: $userId, reason: $reason, permanent: $permanent) { success }
}
mutation AdminUnsuspendUser($userId: ID!, $reason: String) { adminUnsuspendUser(userId: $userId, reason: $reason) { success } }
mutation AdminUnbanUser($userId: ID!, $reason: String) { adminUnbanUser(userId: $userId, reason: $reason) { success } }
mutation AdminRemoveUserContent($userId: ID!, $contentType: String!, $contentIds: [ID!]!) {
adminRemoveUserContent(userId: $userId, contentType: $contentType, contentIds: $contentIds) { success }
}
Bulk moderation
mutation AdminBulkSuspend($userIds: [ID!]!, $reason: String!, $durationDays: Int) {
adminBulkSuspendUsers(userIds: $userIds, reason: $reason, durationDays: $durationDays) {
successCount failedCount
}
}
mutation AdminBulkBan($userIds: [ID!]!, $reason: String!, $permanent: Boolean) {
adminBulkBanUsers(userIds: $userIds, reason: $reason, permanent: $permanent) {
successCount failedCount
}
}
adminBulkBanUsers is super_admin-gated and calls banUser per user (deleteContent: false, notifyUser: false); backend-ready but not wired to any frontend-admin UI yet.
Verification
mutation AdminVerifyUser($userId: ID!) { adminVerifyUser(userId: $userId) { success } }
mutation AdminRemoveVerification($userId: ID!, $reason: String) { adminRemoveVerification(userId: $userId, reason: $reason) { success } }
These are separate from the client-schema verification request flow (getPendingVerificationRequests / grantVerification / removeVerification / rejectVerificationRequest, documented in Verification) — two independent implementations of similar functionality, on different schemas.
Content moderation
Flagged content and actions
adminApproveContent (gated MODERATE_CONTENT) and adminRejectContent (gated REMOVE_CONTENT) both have real resolvers delegating to contentModerationManager.approveContent/rejectContent.
query AdminGetFlaggedContent($contentType: String, $limit: Int, $offset: Int) {
adminGetFlaggedContent(contentType: $contentType, limit: $limit, offset: $offset) {
total items { id contentType contentId reason flaggedAt }
}
}
query AdminGetContentDetails($contentType: String!, $contentId: ID!) {
adminGetContentDetails(contentType: $contentType, contentId: $contentId) { id contentType }
}
query AdminGetContentModerationHistory($contentType: String!, $contentId: ID!) {
adminGetContentModerationHistory(contentType: $contentType, contentId: $contentId) { id action performedAt }
}
query AdminGetContentModerationStats($startDate: DateTime, $endDate: DateTime) {
adminGetContentModerationStats(startDate: $startDate, endDate: $endDate) { totalReviewed totalRemoved }
}
query AdminGetTrendingContent($limit: Int) { adminGetTrendingContent(limit: $limit) { id contentType } }
mutation AdminRemoveContent($contentType: String!, $contentId: ID!, $reason: String!) {
adminRemoveContent(contentType: $contentType, contentId: $contentId, reason: $reason) { success }
}
mutation AdminRestoreContent($contentType: String!, $contentId: ID!) {
adminRestoreContent(contentType: $contentType, contentId: $contentId) { success }
}
mutation AdminBulkRemoveContent($items: [ContentIdentifierInput!]!, $reason: String!) {
adminBulkRemoveContent(items: $items, reason: $reason) { successCount failedCount }
}
mutation AdminFlagContent($contentType: String!, $contentId: ID!, $reason: String!) {
adminFlagContent(contentType: $contentType, contentId: $contentId, reason: $reason) { success }
}
mutation AdminUnflagContent($contentType: String!, $contentId: ID!) {
adminUnflagContent(contentType: $contentType, contentId: $contentId) { success }
}
Auto-moderation rules
Real resolvers exist in graphql/resolvers/admin/content-moderation.resolver.js, delegating to real CRUD in managers/admin-managers/content-moderation.manager.js against a real auto_moderation_rule DB table (model AutoModerationRule.js), with a working list/create/edit UI at /moderation/rules. Rules are also actually enforced, not just stored — services/auto-moderation.service.js matches keyword/regex/nsfw_score/report_threshold rule types against content and applies flag/remove/warn actions, called (best-effort, non-blocking) from post.manager.js on every post creation.
query AdminGetAutoModerationRules { adminGetAutoModerationRules { id name enabled } }
mutation AdminCreateAutoModerationRule($input: AutoModerationRuleInput!) { adminCreateAutoModerationRule(input: $input) { id } }
mutation AdminUpdateAutoModerationRule($id: ID!, $input: AutoModerationRuleInput!) { adminUpdateAutoModerationRule(id: $id, input: $input) { id } }
mutation AdminDeleteAutoModerationRule($id: ID!) { adminDeleteAutoModerationRule(id: $id) { success } }
Content warnings
adminAddContentWarning's argument-name bug is fixed — the resolver now destructures warning/severity, matching the schema. adminRemoveContentWarning's resolver still ignores the schema's warningId argument entirely (the manager's removeContentWarning has no such parameter) — a real call still silently passes undefined for it.
mutation AdminAddContentWarning($contentType: String!, $contentId: ID!, $warning: String!, $severity: String) {
adminAddContentWarning(contentType: $contentType, contentId: $contentId, warning: $warning, severity: $severity) { success }
}
mutation AdminRemoveContentWarning($contentType: String!, $contentId: ID!, $warningId: ID!) {
adminRemoveContentWarning(contentType: $contentType, contentId: $contentId, warningId: $warningId) { success }
}
Report review queue
adminEscalateReport and adminDismissReport were removed from the schema entirely (both the admin and client-facing content-report.type.js copies, resolvers deleted) — they are no longer callable. Escalation happens via adminReviewReport with action: ESCALATE (sets status to reviewing), and dismissal via adminReviewReport with action: REJECT (sets status to dismissed) — both already what the moderation page's Escalar/Rechazar buttons use.
query AdminGetReports($status: ReportStatus, $contentType: ContentType, $reportType: ReportReason, $limit: Int, $offset: Int) {
adminGetReports(status: $status, contentType: $contentType, reportType: $reportType, limit: $limit, offset: $offset) {
total limit offset
reports { id reporterId contentType contentId reason status createdAt }
}
}
query AdminGetReportsByUser($userId: ID!, $limit: Int, $offset: Int) {
adminGetReportsByUser(userId: $userId, limit: $limit, offset: $offset) { total reports { id status } }
}
query AdminGetReportStats($startDate: DateTime, $endDate: DateTime) {
adminGetReportStats(startDate: $startDate, endDate: $endDate) { total pending resolved }
}
query AdminGetReportDetails($reportId: ID!) { adminGetReportDetails(reportId: $reportId) { id status } }
mutation AdminReviewReport($reportId: ID!, $action: String!, $notes: String, $removeContent: Boolean) {
adminReviewReport(reportId: $reportId, action: $action, notes: $notes, removeContent: $removeContent) { success }
}
mutation AdminBulkReviewReports($reportIds: [ID!]!, $action: String!, $notes: String) {
adminBulkReviewReports(reportIds: $reportIds, action: $action, notes: $notes) { successCount failedCount }
}
Comment controls
Implemented inside content-moderation.resolver.js, despite being declared in a separate post-actions.type.js file (there's no dedicated post-actions.resolver.js).
mutation AdminDisableComments($contentType: String!, $contentId: ID!) { adminDisableComments(contentType: $contentType, contentId: $contentId) { success } }
mutation AdminEnableComments($contentType: String!, $contentId: ID!) { adminEnableComments(contentType: $contentType, contentId: $contentId) { success } }
Admin account management
Admin accounts are self-managed through this same admin schema. Three roles exist: moderator, admin, super_admin (see Admin roles).
Own profile and login
query AdminMe { adminMe { id username email role permissions twoFactorEnabled lastLoginAt } }
mutation AdminLogin($input: AdminLoginInput!) {
adminLogin(input: $input) { success token refreshToken admin requires2FA tempToken }
}
mutation AdminUpdateProfile($input: AdminProfileUpdateInput!) { adminUpdateProfile(input: $input) { id email } }
mutation AdminChangePassword($input: AdminPasswordChangeInput!) { adminChangePassword(input: $input) }
# Unauthenticated - the refresh token itself is the credential. Always
# rotates: the passed-in refresh token stops working the instant this
# succeeds, whether or not the caller uses the new one. Never throws a
# GraphQL error - a revoked/expired refresh token comes back as
# {success: false, message}.
mutation AdminRefreshToken($refreshToken: String!) {
adminRefreshToken(refreshToken: $refreshToken) { success token refreshToken expiresAt message }
}
Admin 2FA
All admin-2FA mutations work correctly, including adminConfirm2FA, adminDisable2FA, and adminRegenerateBackupCodes — each previously returned a wrapper object (e.g. { success: result } or { backupCodes }) where the schema declares a plain scalar (Boolean! / [String!]!), which graphql-js's scalar serializer rejected; all three now return the raw scalar.
mutation AdminVerify2FA($tempToken: String!, $code: String!) {
adminVerify2FA(tempToken: $tempToken, code: $code) { success token refreshToken admin }
}
mutation AdminVerifyBackupCode($tempToken: String!, $backupCode: String!) {
adminVerifyBackupCode(tempToken: $tempToken, backupCode: $backupCode) { success token refreshToken remainingBackupCodes }
}
mutation AdminEnable2FA { adminEnable2FA { secret qrCode backupCodes } }
mutation AdminConfirm2FA($code: String!) { adminConfirm2FA(code: $code) }
mutation AdminDisable2FA($password: String!) { adminDisable2FA(password: $password) }
mutation AdminRegenBackupCodes($password: String!) { adminRegenerateBackupCodes(password: $password) }
Own sessions
adminActiveSessions, adminRevokeSession, and adminRevokeAllSessions operate on the calling admin's own login sessions (keyed by admin.adminId from the JWT) — not on other users' or other admins' sessions. All three work correctly; adminRevokeSession and adminRevokeAllSessions previously had the same wrapper-object-vs-scalar mismatch described above and now return the raw Boolean!.
query AdminActiveSessions { adminActiveSessions { id deviceName browser os ip location lastActivity isCurrent } }
mutation AdminRevokeSession($sessionId: ID!) { adminRevokeSession(sessionId: $sessionId) }
mutation AdminRevokeAllSessions { adminRevokeAllSessions }
Managing other admins
Super_admin only.
adminActivate and adminDelete now work correctly — both previously had the same wrapper-object-vs-scalar mismatch and now return the raw Boolean!.
query AdminUser($id: ID!) { adminUser(id: $id) { id username role isActive } }
query AdminUsers($role: AdminRole, $isActive: Boolean) {
adminUsers(role: $role, isActive: $isActive) { id username email role isActive lastLoginAt }
}
query AdminStats { adminStats { totalAdmins activeAdmins adminsByRole } }
mutation AdminRegister($input: AdminRegistrationInput!) { adminRegister(input: $input) { id username role } }
mutation AdminDeactivate($adminId: ID!) { adminDeactivate(adminId: $adminId) }
mutation AdminActivate($adminId: ID!) { adminActivate(adminId: $adminId) }
mutation AdminDelete($adminId: ID!, $reason: String) { adminDelete(adminId: $adminId, reason: $reason) }
mutation AdminBulkDeactivate($adminIds: [ID!]!, $reason: String) { adminBulkDeactivate(adminIds: $adminIds, reason: $reason) { successCount failedCount } }
mutation AdminBulkActivate($adminIds: [ID!]!, $reason: String) { adminBulkActivate(adminIds: $adminIds, reason: $reason) { successCount failedCount } }
Permissions and roles
query AdminHasPermission($permission: String!) { adminHasPermission(permission: $permission) }
query AdminPermissions($adminId: ID) { adminPermissions(adminId: $adminId) { permission description category hasPermission } }
mutation AdminUpdateRole($adminId: ID!, $input: AdminRoleUpdateInput!) {
adminUpdateRoleAndPermissions(adminId: $adminId, input: $input) { id role permissions }
}
mutation AdminBulkUpdatePermissions($updates: [AdminBulkPermissionUpdate!]!, $reason: String) {
adminBulkUpdatePermissions(updates: $updates, reason: $reason) { successCount failedCount }
}