diff --git a/packages/web/src/Routes.jsx b/packages/web/src/Routes.jsx index 8be85187a..96316f832 100644 --- a/packages/web/src/Routes.jsx +++ b/packages/web/src/Routes.jsx @@ -16,6 +16,7 @@ import SettingsPage from '@components/profile-ui/SettingsPage.jsx'; import BillingPage from '@components/billing/BillingPage.jsx'; import NotFoundPage from '@components/NotFoundPage.jsx'; import { AdminDashboard } from '@components/admin-ui/index.js'; +import StorageManagement from '@components/admin-ui/StorageManagement.jsx'; import { BASEPATH } from '@config/api.js'; import ProtectedGuard from '@/components/auth-ui/ProtectedGuard.jsx'; @@ -40,6 +41,7 @@ export default function AppRoutes() { + diff --git a/packages/web/src/components/admin-ui/AdminDashboard.jsx b/packages/web/src/components/admin-ui/AdminDashboard.jsx index 6e8785288..7cbf06f37 100644 --- a/packages/web/src/components/admin-ui/AdminDashboard.jsx +++ b/packages/web/src/components/admin-ui/AdminDashboard.jsx @@ -3,7 +3,7 @@ */ import { createSignal, createResource, Show, onMount } from 'solid-js'; -import { useNavigate } from '@solidjs/router'; +import { useNavigate, A } from '@solidjs/router'; import { FiUsers, FiFolder, @@ -14,6 +14,7 @@ import { FiChevronRight, FiShield, FiAlertCircle, + FiDatabase, } from 'solid-icons/fi'; import { isAdmin, @@ -94,6 +95,13 @@ export default function AdminDashboard() {

Manage users and monitor activity

+ + + Storage Management + {/* Stats Grid */} diff --git a/packages/web/src/components/admin-ui/StorageManagement.jsx b/packages/web/src/components/admin-ui/StorageManagement.jsx new file mode 100644 index 000000000..840c3ed5e --- /dev/null +++ b/packages/web/src/components/admin-ui/StorageManagement.jsx @@ -0,0 +1,471 @@ +/** + * Storage Management - Admin interface for managing R2 documents + */ + +import { createSignal, createResource, Show, For, onMount } from 'solid-js'; +import { useNavigate } from '@solidjs/router'; +import { + FiTrash2, + FiSearch, + FiChevronLeft, + FiChevronRight, + FiDatabase, + FiAlertCircle, + FiCheckSquare, + FiSquare, +} from 'solid-icons/fi'; +import { + isAdmin, + isAdminChecked, + checkAdminStatus, + fetchStorageDocuments, + deleteStorageDocuments, +} from '@/stores/adminStore.js'; +import { Dialog, showToast } from '@corates/ui'; + +function formatFileSize(bytes) { + if (!bytes) return '0 B'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +function formatDate(timestamp) { + if (!timestamp) return '-'; + return new Date(timestamp).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +export default function StorageManagement() { + const navigate = useNavigate(); + const [search, setSearch] = createSignal(''); + const [prefix, setPrefix] = createSignal(''); + const [cursor, setCursor] = createSignal(null); + const [cursorHistory, setCursorHistory] = createSignal([]); + const [debouncedSearch, setDebouncedSearch] = createSignal(''); + const [selectedKeys, setSelectedKeys] = createSignal(new Set()); + const [deleteDialog, setDeleteDialog] = createSignal(null); + const [loading, setLoading] = createSignal(false); + + const limit = 50; + + // Check admin status on mount + onMount(async () => { + await checkAdminStatus(); + if (!isAdmin()) { + navigate('/dashboard'); + } + }); + + // Fetch documents with cursor-based pagination + const [documentsData, { refetch: refetchDocuments }] = createResource( + () => ({ cursor: cursor(), limit, prefix: prefix(), search: debouncedSearch() }), + fetchStorageDocuments, + ); + + // Debounce search + let searchTimeout; + const handleSearchInput = e => { + setSearch(e.target.value); + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + setDebouncedSearch(e.target.value); + setCursor(null); + setCursorHistory([]); + setSelectedKeys(new Set()); + }, 300); + }; + + const handlePrefixChange = e => { + setPrefix(e.target.value); + setCursor(null); + setCursorHistory([]); + setSelectedKeys(new Set()); + }; + + const handleNextPage = () => { + const data = documentsData(); + if (data?.nextCursor) { + setCursorHistory(prev => [...prev, cursor()]); + setCursor(data.nextCursor); + } + }; + + const handlePrevPage = () => { + const history = cursorHistory(); + if (history.length > 0) { + const prevCursor = history[history.length - 1]; + setCursorHistory(prev => prev.slice(0, -1)); + setCursor(prevCursor); + } else { + setCursor(null); + } + }; + + const toggleSelect = key => { + const newSet = new Set(selectedKeys()); + if (newSet.has(key)) { + newSet.delete(key); + } else { + newSet.add(key); + } + setSelectedKeys(newSet); + }; + + const toggleSelectAll = () => { + const docs = documentsData()?.documents || []; + const currentKeys = new Set(docs.map(d => d.key)); + const selected = selectedKeys(); + + // Check if all current page documents are selected + const allCurrentPageSelected = docs.length > 0 && docs.every(doc => selected.has(doc.key)); + + if (allCurrentPageSelected) { + // Deselect only current page documents, keep other selections + const newSet = new Set(selected); + currentKeys.forEach(key => newSet.delete(key)); + setSelectedKeys(newSet); + } else { + // Select all current page documents, keep other selections + const newSet = new Set(selected); + currentKeys.forEach(key => newSet.add(key)); + setSelectedKeys(newSet); + } + }; + + const handleDelete = async () => { + const keysToDelete = deleteDialog(); + if (!keysToDelete || keysToDelete.length === 0) return; + + setLoading(true); + try { + const result = await deleteStorageDocuments(keysToDelete); + setDeleteDialog(null); + setSelectedKeys(new Set()); + + if (result.failed > 0) { + showToast.warning( + 'Partial Delete Success', + `Deleted ${result.deleted} documents. ${result.failed} failed.`, + ); + } else { + showToast.success('Documents Deleted', `Successfully deleted ${result.deleted} documents.`); + } + + refetchDocuments(); + } catch (error) { + showToast.error('Delete Failed', error.message || 'Failed to delete documents'); + } finally { + setLoading(false); + } + }; + + const handleBulkDelete = () => { + const keys = Array.from(selectedKeys()); + if (keys.length === 0) return; + setDeleteDialog(keys); + }; + + const handleSingleDelete = key => { + setDeleteDialog([key]); + }; + + return ( + +
+
+ } + > + + +

Access Denied

+

You do not have admin privileges.

+ + } + > +
+ {/* Header */} +
+
+ +
+ +
+
+

Storage Management

+

Manage documents in R2 storage

+
+
+
+ + {/* Info Banner */} +
+

+ Note: This dashboard shows all PDFs in R2 storage. PDFs marked as + "Orphaned" have a project ID that no longer exists in the database (e.g., from failed + cleanup when projects were deleted). You can safely delete orphaned PDFs to free up + storage space. +

+
+ + {/* Filters and Search */} +
+
+ + +
+
+ +
+
+ + {/* Bulk Actions Bar */} + 0}> +
+ + {selectedKeys().size} document{selectedKeys().size === 1 ? '' : 's'} selected + +
+ + +
+
+
+ + {/* Documents Table */} +
+
+ + + + + + + + + + + + + + + + + } + > + + + + } + > + {doc => ( + + + + + + + + + + )} + + + +
+ + + File Name + + Size + + Project ID + + Study ID + + Uploaded + + Actions +
+
+
+
+
+ No documents found +
+ + +
+ {doc.fileName} + + + Orphaned + + +
+
+ {formatFileSize(doc.size)} + + {doc.projectId} + + {doc.studyId} + + {formatDate(doc.uploaded)} + + +
+
+ + {/* Pagination */} + +
+
+

+ Showing {documentsData()?.documents?.length || 0} document + {documentsData()?.documents?.length === 1 ? '' : 's'} +

+ +

+ Results truncated after processing 10,000 objects. Use pagination to continue. +

+
+
+
+ + + {cursorHistory().length + 1} + {documentsData()?.nextCursor ? ' →' : ''} + + +
+
+
+
+
+ + {/* Delete Confirmation Dialog */} + !open && setDeleteDialog(null)} + title='Delete Documents' + role='alertdialog' + > +
+

+ Are you sure you want to delete {deleteDialog()?.length || 0} document + {deleteDialog()?.length === 1 ? '' : 's'}? This action cannot be undone. +

+
+ + +
+
+
+
+
+ ); +} diff --git a/packages/web/src/components/project-ui/overview-tab/OverviewTab.jsx b/packages/web/src/components/project-ui/overview-tab/OverviewTab.jsx index aa2094641..392f554e9 100644 --- a/packages/web/src/components/project-ui/overview-tab/OverviewTab.jsx +++ b/packages/web/src/components/project-ui/overview-tab/OverviewTab.jsx @@ -288,7 +288,7 @@ export default function OverviewTab() {
({})); + throw new Error(error.error || 'Failed to fetch storage documents'); + } + return response.json(); +} + +/** + * Delete storage documents (bulk) + */ +async function deleteStorageDocuments(keys) { + if (!Array.isArray(keys) || keys.length === 0) { + throw new Error('Keys array is required'); + } + + const response = await fetch(`${API_BASE}/api/admin/storage/documents`, { + method: 'DELETE', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ keys }), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.error || 'Failed to delete storage documents'); + } + return response.json(); +} + +/** + * Fetch storage statistics + */ +async function fetchStorageStats() { + const response = await fetch(`${API_BASE}/api/admin/storage/stats`, { + credentials: 'include', + }); + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.error || 'Failed to fetch storage stats'); + } + return response.json(); +} + export { isAdmin, isAdminChecked, @@ -269,4 +325,7 @@ export { deleteUser, grantAccess, revokeAccess, + fetchStorageDocuments, + deleteStorageDocuments, + fetchStorageStats, }; diff --git a/packages/workers/scripts/generate-openapi.mjs b/packages/workers/scripts/generate-openapi.mjs index fddc7953a..f18d8753b 100644 --- a/packages/workers/scripts/generate-openapi.mjs +++ b/packages/workers/scripts/generate-openapi.mjs @@ -255,13 +255,15 @@ async function generate() { { file: 'src/routes/users.js', basePath: '/api/users' }, { file: 'src/routes/pdfs.js', basePath: '/api/projects/{projectId}/studies/{studyId}/pdfs' }, { file: 'src/routes/billing/index.js', basePath: '/api/billing' }, - { file: 'src/routes/admin.js', basePath: '/api/admin' }, + { file: 'src/routes/admin/index.js', basePath: '/api/admin' }, { file: 'src/routes/contact.js', basePath: '/api/contact' }, { file: 'src/routes/avatars.js', basePath: '/api/users/avatar' }, { file: 'src/routes/google-drive.js', basePath: '/api/google-drive' }, { file: 'src/routes/account-merge.js', basePath: '/api/accounts/merge' }, { file: 'src/routes/email.js', basePath: '/api/email' }, { file: 'src/routes/database.js', basePath: '/api/db' }, + { file: 'src/routes/admin/users.js', basePath: '/api/admin' }, + { file: 'src/routes/admin/storage.js', basePath: '/api/admin' }, ]; // Parse all route files diff --git a/packages/workers/src/__tests__/admin.test.js b/packages/workers/src/__tests__/admin.test.js index e5a01fe90..de9904381 100644 --- a/packages/workers/src/__tests__/admin.test.js +++ b/packages/workers/src/__tests__/admin.test.js @@ -264,7 +264,7 @@ async function fetchApp(path, init = {}) { } beforeAll(async () => { - const { adminRoutes } = await import('../routes/admin.js'); + const { adminRoutes } = await import('../routes/admin/index.js'); app = new Hono(); app.route('/api/admin', adminRoutes); }); diff --git a/packages/workers/src/config/validation.js b/packages/workers/src/config/validation.js index 0b8756b69..ecc12e812 100644 --- a/packages/workers/src/config/validation.js +++ b/packages/workers/src/config/validation.js @@ -90,6 +90,18 @@ export const userSchemas = { return Math.min(Math.max(1, num), 20); }), }), + ban: z.object({ + reason: z.string().optional(), + expiresAt: z + .union([ + z + .string() + .datetime('expiresAt must be a valid ISO datetime string') + .transform(val => new Date(val)), + z.null(), + ]) + .optional(), + }), }; /** @@ -133,6 +145,50 @@ export const subscriptionSchemas = { }), }; +/** + * Storage management schemas + */ +export const storageSchemas = { + listDocuments: z.object({ + cursor: z + .string() + .optional() + .transform(val => (val ? val.trim() : undefined)), + limit: z + .string() + .optional() + .default('50') + .transform(val => parseInt(val, 10)) + .pipe( + z + .number() + .int('Limit must be an integer') + .min(1, 'Limit must be at least 1') + .max(1000, 'Limit must be at most 1000'), + ), + prefix: z + .string() + .optional() + .transform(val => (val ? val.trim() : '')), + search: z + .string() + .optional() + .transform(val => (val ? val.trim().toLowerCase() : '')), + }), + deleteDocuments: z.object({ + keys: z + .array( + z + .string() + .min(1, 'Key cannot be empty') + .refine(key => /^projects\/[^/]+\/studies\/[^/]+\/.+$/.test(key), { + message: 'Key must match pattern: projects/{projectId}/studies/{studyId}/{fileName}', + }), + ) + .min(1, 'At least one key is required'), + }), +}; + /** * Map Zod error to validation error code * @param {object} issue - Zod issue object from error.issues array diff --git a/packages/workers/src/index.js b/packages/workers/src/index.js index 098067a99..1a88be753 100644 --- a/packages/workers/src/index.js +++ b/packages/workers/src/index.js @@ -24,7 +24,7 @@ import { emailRoutes } from './routes/email.js'; import { billingRoutes } from './routes/billing/index.js'; import { googleDriveRoutes } from './routes/google-drive.js'; import { avatarRoutes } from './routes/avatars.js'; -import { adminRoutes } from './routes/admin.js'; +import { adminRoutes } from './routes/admin/index.js'; import { accountMergeRoutes } from './routes/account-merge.js'; import { contactRoutes } from './routes/contact.js'; diff --git a/packages/workers/src/routes/admin/index.js b/packages/workers/src/routes/admin/index.js new file mode 100644 index 000000000..0e48b7e4c --- /dev/null +++ b/packages/workers/src/routes/admin/index.js @@ -0,0 +1,23 @@ +/** + * Admin routes for Hono + * Provides admin dashboard API endpoints + */ + +import { Hono } from 'hono'; +import { requireAdmin } from '../../middleware/requireAdmin.js'; +import { requireTrustedOrigin } from '../../middleware/csrf.js'; +import { userRoutes } from './users.js'; +import { storageRoutes } from './storage.js'; + +const adminRoutes = new Hono(); + +// Apply admin middleware to all routes +adminRoutes.use('*', requireAdmin); +// CSRF guard for all state-changing admin routes +adminRoutes.use('*', requireTrustedOrigin); + +// Mount route handlers +adminRoutes.route('/', userRoutes); +adminRoutes.route('/', storageRoutes); + +export { adminRoutes }; diff --git a/packages/workers/src/routes/admin/storage.js b/packages/workers/src/routes/admin/storage.js new file mode 100644 index 000000000..55ff92bd9 --- /dev/null +++ b/packages/workers/src/routes/admin/storage.js @@ -0,0 +1,300 @@ +/** + * Admin storage management routes + * Handles R2 document listing, deletion, and statistics + */ + +import { Hono } from 'hono'; +import { createDb } from '../../db/client.js'; +import { projects } from '../../db/schema.js'; +import { inArray } from 'drizzle-orm'; +import { createDomainError, SYSTEM_ERRORS } from '@corates/shared'; +import { storageSchemas, validateQueryParams, validateRequest } from '../../config/validation.js'; + +const storageRoutes = new Hono(); + +/** + * Parse project/study IDs from R2 key pattern: projects/{projectId}/studies/{studyId}/{fileName} + */ +function parseKey(key) { + const match = key.match(/^projects\/([^/]+)\/studies\/([^/]+)\/(.+)$/); + if (!match) { + return null; + } + return { + projectId: match[1], + studyId: match[2], + fileName: match[3], + }; +} + +/** + * GET /api/admin/storage/documents + * List documents with cursor-based pagination + * Query params: + * - cursor: optional cursor token from previous response to continue pagination + * - limit: results per page (default 50, max 1000) + * - prefix: filter by prefix (e.g., "projects/{projectId}/") + * - search: filter by file name (case-insensitive substring match) + * + * Returns: + * - documents: array of matching documents + * - nextCursor: cursor token for next page (if more results available) + * - truncated: true if processing was stopped due to cap (10k objects processed) + */ +storageRoutes.get( + '/storage/documents', + validateQueryParams(storageSchemas.listDocuments), + async c => { + try { + const { cursor: requestCursor, limit, prefix, search } = c.get('validatedQuery'); + + // Decode composite cursor: {r2Cursor: string | undefined, skipCount: number} + let r2Cursor = undefined; + let skipCount = 0; + if (requestCursor) { + try { + const decoded = JSON.parse(requestCursor); + r2Cursor = decoded.r2Cursor; + skipCount = Math.max(0, decoded.skipCount || 0); + // If r2Cursor is undefined, we're starting from beginning of R2, so reset skipCount + if (!r2Cursor) { + skipCount = 0; + } + } catch { + // Invalid cursor format, treat as first request + r2Cursor = requestCursor; + skipCount = 0; + } + } + + const PROCESSING_CAP = 10000; + const matchingObjects = []; + let currentCursor = r2Cursor; + let objectsProcessed = 0; + let truncated = false; + let listedTruncated = false; + + // Process batches until we have enough matching results (skipCount + limit) or hit the cap + while (matchingObjects.length < skipCount + limit && objectsProcessed < PROCESSING_CAP) { + const listOptions = { + limit: 1000, + prefix: prefix || undefined, + }; + if (currentCursor) { + listOptions.cursor = currentCursor; + } + + const listed = await c.env.PDF_BUCKET.list(listOptions); + + if (listed.objects.length === 0) { + listedTruncated = false; + break; + } + + objectsProcessed += listed.objects.length; + + // Process the entire batch to avoid skipping objects when we hit the limit + for (const obj of listed.objects) { + const parsed = parseKey(obj.key); + if (!parsed) { + continue; + } + + // Apply search filter if provided + if (search && !parsed.fileName.toLowerCase().includes(search)) { + continue; + } + + matchingObjects.push({ + key: obj.key, + fileName: parsed.fileName, + projectId: parsed.projectId, + studyId: parsed.studyId, + size: obj.size, + uploaded: obj.uploaded, + etag: obj.etag, + }); + } + + // Update cursor for next batch + if (listed.truncated) { + currentCursor = listed.cursor; + listedTruncated = true; + } else { + listedTruncated = false; + break; + } + + // Stop if we hit the processing cap + if (objectsProcessed >= PROCESSING_CAP) { + truncated = true; + break; + } + } + + // Guard against skipCount exceeding matchingObjects.length + skipCount = Math.min(skipCount, matchingObjects.length); + + // Slice with offset to get the correct page + const paginatedObjects = matchingObjects.slice(skipCount, skipCount + limit); + + // Check which projects exist in the database to identify orphaned PDFs + const uniqueProjectIds = [...new Set(paginatedObjects.map(doc => doc.projectId))]; + let existingProjectIds = new Set(); + + // Only query database if there are project IDs to check + // inArray with empty array generates invalid SQL (WHERE id IN ()) + if (uniqueProjectIds.length > 0) { + const db = createDb(c.env.DB); + const existingProjects = await db + .select({ id: projects.id }) + .from(projects) + .where(inArray(projects.id, uniqueProjectIds)); + + existingProjectIds = new Set(existingProjects.map(p => p.id)); + } + + // Mark documents as orphaned if their project doesn't exist + const documentsWithOrphanStatus = paginatedObjects.map(doc => ({ + ...doc, + orphaned: !existingProjectIds.has(doc.projectId), + })); + + const response = { + documents: documentsWithOrphanStatus, + limit, + }; + + // Determine if there are more results available + // Return nextCursor only if: + // 1. There are more R2 objects to process (listedTruncated && currentCursor), OR + // 2. We hit the processing cap AND have a cursor to continue from + // Note: If R2 is exhausted (listedTruncated = false), we can't persist cached matches + // across requests, so we don't return a nextCursor even if we have cached matches + const hasMoreR2Objects = listedTruncated && currentCursor; + const canContinueAfterCap = truncated && currentCursor; + + // Include nextCursor if there are more results available and we have a cursor to continue from + if (hasMoreR2Objects || canContinueAfterCap) { + const nextSkipCount = Math.max(0, skipCount + paginatedObjects.length); + const nextCursorData = { + r2Cursor: currentCursor, + skipCount: nextSkipCount, + }; + response.nextCursor = JSON.stringify(nextCursorData); + } + + // Include truncated flag if we hit the processing cap + if (truncated) { + response.truncated = true; + } + + return c.json(response); + } catch (error) { + console.error('Error listing storage documents:', error); + const systemError = createDomainError(SYSTEM_ERRORS.INTERNAL_ERROR, { + operation: 'list_storage_documents', + originalError: error.message, + }); + return c.json(systemError, systemError.statusCode); + } + }, +); + +/** + * DELETE /api/admin/storage/documents + * Bulk delete documents + * Body: { keys: string[] } + */ +storageRoutes.delete( + '/storage/documents', + validateRequest(storageSchemas.deleteDocuments), + async c => { + try { + const { keys } = c.get('validatedBody'); + + // Delete all keys in parallel + const deleteResults = await Promise.allSettled(keys.map(key => c.env.PDF_BUCKET.delete(key))); + + const deleted = deleteResults.filter(r => r.status === 'fulfilled').length; + const failed = deleteResults.filter(r => r.status === 'rejected').length; + + let errors; + if (failed > 0) { + errors = []; + deleteResults.forEach((result, i) => { + if (result.status === 'rejected') { + errors.push({ + key: keys[i], + error: result.reason?.message || 'Unknown error', + }); + } + }); + } + + return c.json({ + deleted, + failed, + errors, + }); + } catch (error) { + console.error('Error deleting storage documents:', error); + const systemError = createDomainError(SYSTEM_ERRORS.INTERNAL_ERROR, { + operation: 'delete_storage_documents', + originalError: error.message, + }); + return c.json(systemError, systemError.statusCode); + } + }, +); + +/** + * GET /api/admin/storage/stats + * Get storage statistics + */ +storageRoutes.get('/storage/stats', async c => { + try { + let cursor = undefined; + let totalFiles = 0; + let totalSize = 0; + const filesByProject = {}; + + // Iterate through all objects to calculate stats + do { + const listed = await c.env.PDF_BUCKET.list({ + limit: 1000, + cursor, + }); + + for (const obj of listed.objects) { + totalFiles++; + totalSize += obj.size; + + const parsed = parseKey(obj.key); + if (parsed) { + filesByProject[parsed.projectId] = (filesByProject[parsed.projectId] || 0) + 1; + } + } + + cursor = listed.truncated ? listed.cursor : undefined; + } while (cursor); + + return c.json({ + totalFiles, + totalSize, + filesByProject: Object.entries(filesByProject).map(([projectId, count]) => ({ + projectId, + count, + })), + }); + } catch (error) { + console.error('Error fetching storage stats:', error); + const systemError = createDomainError(SYSTEM_ERRORS.INTERNAL_ERROR, { + operation: 'fetch_storage_stats', + originalError: error.message, + }); + return c.json(systemError, systemError.statusCode); + } +}); + +export { storageRoutes }; diff --git a/packages/workers/src/routes/admin.js b/packages/workers/src/routes/admin/users.js similarity index 92% rename from packages/workers/src/routes/admin.js rename to packages/workers/src/routes/admin/users.js index f37512fa8..7b4974e94 100644 --- a/packages/workers/src/routes/admin.js +++ b/packages/workers/src/routes/admin/users.js @@ -1,10 +1,10 @@ /** - * Admin routes for Hono - * Provides admin dashboard API endpoints for user management + * Admin user management routes + * Handles user CRUD operations, bans, impersonation, subscriptions, and stats */ import { Hono } from 'hono'; -import { createDb } from '../db/client.js'; +import { createDb } from '../../db/client.js'; import { user, session, @@ -14,11 +14,9 @@ import { verification, twoFactor, subscriptions, -} from '../db/schema.js'; +} from '../../db/schema.js'; import { eq, desc, sql, like, or, count } from 'drizzle-orm'; -import { requireAdmin } from '../middleware/requireAdmin.js'; -import { requireTrustedOrigin } from '../middleware/csrf.js'; -import { createAuth } from '../auth/config.js'; +import { createAuth } from '../../auth/config.js'; import { createDomainError, createValidationError, @@ -26,21 +24,17 @@ import { USER_ERRORS, SYSTEM_ERRORS, } from '@corates/shared'; -import { upsertSubscription, getSubscriptionByUserId } from '../db/subscriptions.js'; +import { upsertSubscription, getSubscriptionByUserId } from '../../db/subscriptions.js'; import { getPlan } from '@corates/shared/plans'; -import { subscriptionSchemas, validateRequest } from '../config/validation.js'; -const adminRoutes = new Hono(); +import { subscriptionSchemas, userSchemas, validateRequest } from '../../config/validation.js'; -// Apply admin middleware to all routes -adminRoutes.use('*', requireAdmin); -// CSRF guard for all state-changing admin routes -adminRoutes.use('*', requireTrustedOrigin); +const userRoutes = new Hono(); /** * GET /api/admin/stats * Get dashboard statistics */ -adminRoutes.get('/stats', async c => { +userRoutes.get('/stats', async c => { const db = createDb(c.env.DB); try { @@ -84,7 +78,7 @@ adminRoutes.get('/stats', async c => { * - sort: field to sort by (default createdAt) * - order: asc or desc (default desc) */ -adminRoutes.get('/users', async c => { +userRoutes.get('/users', async c => { const db = createDb(c.env.DB); const page = Math.max(1, parseInt(c.req.query('page') || '1', 10)); @@ -215,7 +209,7 @@ adminRoutes.get('/users', async c => { * GET /api/admin/users/:userId * Get detailed user info */ -adminRoutes.get('/users/:userId', async c => { +userRoutes.get('/users/:userId', async c => { const userId = c.req.param('userId'); const db = createDb(c.env.DB); @@ -292,9 +286,9 @@ adminRoutes.get('/users/:userId', async c => { * POST /api/admin/users/:userId/ban * Ban a user */ -adminRoutes.post('/users/:userId/ban', async c => { +userRoutes.post('/users/:userId/ban', validateRequest(userSchemas.ban), async c => { const userId = c.req.param('userId'); - const { reason, expiresAt } = await c.req.json(); + const { reason, expiresAt } = c.get('validatedBody'); const db = createDb(c.env.DB); try { @@ -317,7 +311,7 @@ adminRoutes.post('/users/:userId/ban', async c => { .set({ banned: true, banReason: reason || 'Banned by administrator', - banExpires: expiresAt ? new Date(expiresAt) : null, + banExpires: expiresAt ?? null, updatedAt: new Date(), }) .where(eq(user.id, userId)), @@ -339,7 +333,7 @@ adminRoutes.post('/users/:userId/ban', async c => { * POST /api/admin/users/:userId/unban * Unban a user */ -adminRoutes.post('/users/:userId/unban', async c => { +userRoutes.post('/users/:userId/unban', async c => { const userId = c.req.param('userId'); const db = createDb(c.env.DB); @@ -370,7 +364,7 @@ adminRoutes.post('/users/:userId/unban', async c => { * Start impersonating a user (creates a new session) * Requires user to have 'admin' role in database */ -adminRoutes.post('/users/:userId/impersonate', async c => { +userRoutes.post('/users/:userId/impersonate', async c => { const userId = c.req.param('userId'); const adminUser = c.get('user'); @@ -430,15 +424,11 @@ adminRoutes.post('/users/:userId/impersonate', async c => { } }); -// Note: stop-impersonation is defined in index.js separately -// because it needs to bypass the requireAdmin middleware -// (the impersonated user won't have admin role) - /** * DELETE /api/admin/users/:userId/sessions * Revoke all sessions for a user */ -adminRoutes.delete('/users/:userId/sessions', async c => { +userRoutes.delete('/users/:userId/sessions', async c => { const userId = c.req.param('userId'); const db = createDb(c.env.DB); @@ -460,7 +450,7 @@ adminRoutes.delete('/users/:userId/sessions', async c => { * DELETE /api/admin/users/:userId * Delete a user and all their data */ -adminRoutes.delete('/users/:userId', async c => { +userRoutes.delete('/users/:userId', async c => { const userId = c.req.param('userId'); const adminUser = c.get('user'); const db = createDb(c.env.DB); @@ -515,7 +505,7 @@ adminRoutes.delete('/users/:userId', async c => { * GET /api/admin/check * Check if current user is admin */ -adminRoutes.get('/check', async c => { +userRoutes.get('/check', async c => { const adminUser = c.get('user'); return c.json({ isAdmin: true, @@ -533,7 +523,7 @@ adminRoutes.get('/check', async c => { * Body: { tier: 'free'|'pro'|'unlimited', status: 'active', currentPeriodStart?: timestamp, currentPeriodEnd?: timestamp } * Admins select the plan (tier), which determines entitlements/quotas via configuration */ -adminRoutes.post( +userRoutes.post( '/users/:userId/subscription', validateRequest(subscriptionSchemas.grant), async c => { @@ -609,7 +599,7 @@ adminRoutes.post( * DELETE /api/admin/users/:userId/subscription * Revoke subscription for a user (sets status to inactive) */ -adminRoutes.delete('/users/:userId/subscription', async c => { +userRoutes.delete('/users/:userId/subscription', async c => { const userId = c.req.param('userId'); const db = createDb(c.env.DB); @@ -649,4 +639,4 @@ adminRoutes.delete('/users/:userId/subscription', async c => { } }); -export { adminRoutes }; +export { userRoutes };