From 99f643ba40467320da7290d8afa38a9ecd914e0f Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Fri, 26 Dec 2025 09:14:05 -0600 Subject: [PATCH 1/9] add r2 document management to admin dashboard --- packages/web/src/Routes.jsx | 2 + .../components/admin-ui/AdminDashboard.jsx | 10 +- .../components/admin-ui/StorageManagement.jsx | 424 ++++++++++++++++++ packages/web/src/stores/adminStore.js | 59 +++ packages/workers/src/index.js | 2 +- packages/workers/src/routes/admin/index.js | 23 + packages/workers/src/routes/admin/storage.js | 263 +++++++++++ .../src/routes/{admin.js => admin/users.js} | 50 +-- 8 files changed, 801 insertions(+), 32 deletions(-) create mode 100644 packages/web/src/components/admin-ui/StorageManagement.jsx create mode 100644 packages/workers/src/routes/admin/index.js create mode 100644 packages/workers/src/routes/admin/storage.js rename packages/workers/src/routes/{admin.js => admin/users.js} (93%) 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..6a0a24f82 --- /dev/null +++ b/packages/web/src/components/admin-ui/StorageManagement.jsx @@ -0,0 +1,424 @@ +/** + * 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 [page, setPage] = createSignal(1); + 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 pagination + const [documentsData, { refetch: refetchDocuments }] = createResource( + () => ({ page: page(), 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); + setPage(1); + setSelectedKeys(new Set()); + }, 300); + }; + + const handlePrefixChange = e => { + setPrefix(e.target.value); + setPage(1); + setSelectedKeys(new Set()); + }; + + 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 || []; + if (selectedKeys().size === docs.length) { + setSelectedKeys(new Set()); + } else { + setSelectedKeys(new Set(docs.map(d => d.key))); + } + }; + + 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]); + }; + + const pagination = () => documentsData()?.pagination; + + 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 {(page() - 1) * limit + 1} to{' '} + {Math.min(page() * limit, pagination()?.total || 0)} of{' '} + {pagination()?.total || 0} documents +

+
+ + + Page {page()} of {Math.ceil((pagination()?.total || 0) / limit) || 1} + + +
+
+
+
+
+ + {/* 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/stores/adminStore.js b/packages/web/src/stores/adminStore.js index 8830ae35a..b3b789eeb 100644 --- a/packages/web/src/stores/adminStore.js +++ b/packages/web/src/stores/adminStore.js @@ -251,6 +251,62 @@ async function revokeAccess(userId) { return response.json(); } +/** + * Fetch storage documents with pagination + */ +async function fetchStorageDocuments({ page = 1, limit = 50, prefix = '', search = '' } = {}) { + const params = new URLSearchParams({ + page: page.toString(), + limit: limit.toString(), + }); + if (prefix) params.set('prefix', prefix); + if (search) params.set('search', search); + + const response = await fetch(`${API_BASE}/api/admin/storage/documents?${params}`, { + credentials: 'include', + }); + if (!response.ok) { + const error = await response.json().catch(() => ({})); + 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/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..04839e47f --- /dev/null +++ b/packages/workers/src/routes/admin/storage.js @@ -0,0 +1,263 @@ +/** + * 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 { eq, inArray } from 'drizzle-orm'; +import { + createDomainError, + createValidationError, + VALIDATION_ERRORS, + SYSTEM_ERRORS, +} from '@corates/shared'; + +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], + }; +} + +/** + * Validate R2 key matches expected pattern + */ +function isValidKey(key) { + return /^projects\/[^/]+\/studies\/[^/]+\/.+$/.test(key); +} + +/** + * GET /api/admin/storage/documents + * List all documents with pagination + * Query params: + * - page: page number (default 1) + * - 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) + */ +storageRoutes.get('/storage/documents', async c => { + try { + const page = Math.max(1, parseInt(c.req.query('page') || '1', 10)); + const limit = Math.min(1000, Math.max(1, parseInt(c.req.query('limit') || '50', 10))); + const prefix = c.req.query('prefix')?.trim() || ''; + const search = c.req.query('search')?.trim()?.toLowerCase() || ''; + + // R2 doesn't support offset, so we need to fetch and filter all matching items + // For large datasets, this could be expensive, but it's necessary for accurate results + let cursor = undefined; + let allObjects = []; + let hasMore = false; + + // Fetch all matching objects (with prefix/search filtering) + do { + const listOptions = { + limit: 1000, + prefix: prefix || undefined, + }; + if (cursor) { + listOptions.cursor = cursor; + } + + const listed = await c.env.PDF_BUCKET.list(listOptions); + + if (listed.objects.length === 0) { + break; + } + + // Parse metadata for each object and apply filters + const parsedObjects = listed.objects + .map(obj => { + const parsed = parseKey(obj.key); + if (!parsed) { + return null; + } + + // Apply search filter if provided + if (search && !parsed.fileName.toLowerCase().includes(search)) { + return null; + } + + return { + key: obj.key, + fileName: parsed.fileName, + projectId: parsed.projectId, + studyId: parsed.studyId, + size: obj.size, + uploaded: obj.uploaded, + etag: obj.etag, + }; + }) + .filter(Boolean); + + allObjects.push(...parsedObjects); + + hasMore = listed.truncated; + cursor = listed.truncated ? listed.cursor : undefined; + } while (cursor); + + // Apply pagination to filtered results + const total = allObjects.length; + const skip = (page - 1) * limit; + const paginatedObjects = allObjects.slice(skip, skip + limit); + + // Check which projects exist in the database to identify orphaned PDFs + const uniqueProjectIds = [...new Set(paginatedObjects.map(doc => doc.projectId))]; + const db = createDb(c.env.DB); + const existingProjects = await db + .select({ id: projects.id }) + .from(projects) + .where(inArray(projects.id, uniqueProjectIds)); + + const 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), + })); + + return c.json({ + documents: documentsWithOrphanStatus, + pagination: { + page, + limit, + total, + hasMore: skip + paginatedObjects.length < total, + }, + }); + } 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', async c => { + try { + const body = await c.req.json(); + const { keys } = body; + + if (!Array.isArray(keys) || keys.length === 0) { + const error = createValidationError( + 'keys', + VALIDATION_ERRORS.FIELD_REQUIRED.code, + keys, + 'keys_array_required', + ); + return c.json(error, error.statusCode); + } + + // Validate all keys match expected pattern + const invalidKeys = keys.filter(key => !isValidKey(key)); + if (invalidKeys.length > 0) { + const error = createValidationError( + 'keys', + VALIDATION_ERRORS.FIELD_INVALID_FORMAT.code, + invalidKeys, + 'invalid_key_format', + ); + return c.json(error, error.statusCode); + } + + // 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; + const errors = + failed > 0 + ? deleteResults + .filter(r => r.status === 'rejected') + .map((r, i) => ({ + key: keys[i], + error: r.reason?.message || 'Unknown error', + })) + : undefined; + + 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 93% rename from packages/workers/src/routes/admin.js rename to packages/workers/src/routes/admin/users.js index f37512fa8..56f657e29 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, 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,7 +286,7 @@ 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', async c => { const userId = c.req.param('userId'); const { reason, expiresAt } = await c.req.json(); const db = createDb(c.env.DB); @@ -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 }; From 2464d267fe3093719f299dc9e4c67f6836089e3f Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Fri, 26 Dec 2025 15:14:35 +0000 Subject: [PATCH 2/9] Apply Prettier formatting --- .../components/admin-ui/StorageManagement.jsx | 21 ++++++++++++++----- packages/workers/src/routes/admin/storage.js | 20 ++++++++---------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/packages/web/src/components/admin-ui/StorageManagement.jsx b/packages/web/src/components/admin-ui/StorageManagement.jsx index 6a0a24f82..0fa23d3d7 100644 --- a/packages/web/src/components/admin-ui/StorageManagement.jsx +++ b/packages/web/src/components/admin-ui/StorageManagement.jsx @@ -252,7 +252,13 @@ export default function StorageManagement() { class='flex items-center text-gray-400 hover:text-gray-600' title='Select all' > - 0} fallback={}> + 0 + } + fallback={} + > @@ -307,7 +313,10 @@ export default function StorageManagement() { onClick={() => toggleSelect(doc.key)} class='text-gray-400 hover:text-gray-600' > - }> + } + > @@ -325,7 +334,9 @@ export default function StorageManagement() { - {formatFileSize(doc.size)} + + {formatFileSize(doc.size)} + {doc.projectId} @@ -357,8 +368,8 @@ export default function StorageManagement() {

Showing {(page() - 1) * limit + 1} to{' '} - {Math.min(page() * limit, pagination()?.total || 0)} of{' '} - {pagination()?.total || 0} documents + {Math.min(page() * limit, pagination()?.total || 0)} of {pagination()?.total || 0}{' '} + documents

- Page {page()} of {Math.ceil((pagination()?.total || 0) / limit) || 1} + {cursorHistory().length + 1} + {documentsData()?.nextCursor ? ' →' : ''}