Skip to main content

Admin Panel

Closegram includes an admin panel (frontend-admin) talking to its own GraphQL schema, for dashboards, user moderation, and content review.

Admin roles

RoleDescription
moderatorCan review reports and moderate content
adminModerator + user management
super_adminFull 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_admin via the ADMIN_REQUIRE_2FA_SUPER_ADMIN env var (defaults to off)
  • IP whitelisting — an AdminIPWhitelist model, access service, and manager, exposed via adminIPWhitelist (query) and adminAddIPWhitelist / 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 logadminGetActivityLog records 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:

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.jsadminDashboardManager.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 }
}