diff --git a/apps/web/messages/af.json b/apps/web/messages/af.json index 1e68baf4..ad54a425 100644 --- a/apps/web/messages/af.json +++ b/apps/web/messages/af.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "Kopieer koppelingsstring", "menuDeleteConnection": "Vee koppeling uit", "toastSidebarStringCopied": "Koppelingsstring gekopieer!", - "cancel": "Kanselleer" + "cancel": "Kanselleer", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n} dokumente", diff --git a/apps/web/messages/ar.json b/apps/web/messages/ar.json index ece4cdb7..bb277a13 100644 --- a/apps/web/messages/ar.json +++ b/apps/web/messages/ar.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "نسخ سلسلة الاتصال", "menuDeleteConnection": "حذف الاتصال", "toastSidebarStringCopied": "تم نسخ سلسلة الاتصال!", - "cancel": "إلغاء" + "cancel": "إلغاء", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n} مستندات", diff --git a/apps/web/messages/ca.json b/apps/web/messages/ca.json index 48f3abcd..49d6d91a 100644 --- a/apps/web/messages/ca.json +++ b/apps/web/messages/ca.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "Copia la cadena de connexió", "menuDeleteConnection": "Elimina la connexió", "toastSidebarStringCopied": "Cadena de connexió copiada!", - "cancel": "Cancel·la" + "cancel": "Cancel·la", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n} docs", diff --git a/apps/web/messages/cs.json b/apps/web/messages/cs.json index 039f0df0..c2dc55f3 100644 --- a/apps/web/messages/cs.json +++ b/apps/web/messages/cs.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "Kopírovat connection string", "menuDeleteConnection": "Smazat připojení", "toastSidebarStringCopied": "Connection string zkopírován!", - "cancel": "Zrušit" + "cancel": "Zrušit", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n} dok.", diff --git a/apps/web/messages/da.json b/apps/web/messages/da.json index 7e0ccaf6..d1d8b0dc 100644 --- a/apps/web/messages/da.json +++ b/apps/web/messages/da.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "Kopier forbindelsesstreng", "menuDeleteConnection": "Slet forbindelse", "toastSidebarStringCopied": "Forbindelsesstrengen er kopieret!", - "cancel": "Ophæve" + "cancel": "Ophæve", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n} dokumenter", diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 7b1f395b..41dbabbd 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "Verbindungszeichenfolge kopieren", "menuDeleteConnection": "Verbindung löschen", "toastSidebarStringCopied": "Verbindungszeichenfolge kopiert!", - "cancel": "Abbrechen" + "cancel": "Abbrechen", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n} Dok.", diff --git a/apps/web/messages/el.json b/apps/web/messages/el.json index 4e2184ec..cdde85bf 100644 --- a/apps/web/messages/el.json +++ b/apps/web/messages/el.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "Αντιγραφή συμβολοσειράς σύνδεσης", "menuDeleteConnection": "Διαγραφή σύνδεσης", "toastSidebarStringCopied": "Η συμβολοσειρά σύνδεσης αντιγράφηκε!", - "cancel": "Ματαίωση" + "cancel": "Ματαίωση", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n} έγγραφα", diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 695f7d99..59fa0751 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1315,7 +1315,10 @@ "menuCopyConnectionString": "Copy Connection String", "menuDeleteConnection": "Delete Connection", "toastSidebarStringCopied": "Connection string copied!", - "cancel": "Cancel" + "cancel": "Cancel", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n} docs", diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index 711df720..e857136e 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "Copiar cadena de conexión", "menuDeleteConnection": "Eliminar conexión", "toastSidebarStringCopied": "¡Cadena de conexión copiada!", - "cancel": "Cancelar" + "cancel": "Cancelar", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n} documentos", diff --git a/apps/web/messages/fa.json b/apps/web/messages/fa.json index 3e551e86..6163fd50 100644 --- a/apps/web/messages/fa.json +++ b/apps/web/messages/fa.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "رشته اتصال را کپی کنید", "menuDeleteConnection": "اتصال را حذف کنید", "toastSidebarStringCopied": "رشته اتصال کپی شد!", - "cancel": "لغو کنید" + "cancel": "لغو کنید", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n} اسناد", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 3bb2e54f..fa654f9a 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "Copier la chaîne de connexion", "menuDeleteConnection": "Supprimer la connexion", "toastSidebarStringCopied": "Chaîne copiée !", - "cancel": "Annuler" + "cancel": "Annuler", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n} docs", diff --git a/apps/web/messages/id.json b/apps/web/messages/id.json index 0f772778..fb34a753 100644 --- a/apps/web/messages/id.json +++ b/apps/web/messages/id.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "String koneksi", "menuDeleteConnection": "Hapus Koneksi", "toastSidebarStringCopied": "String koneksi disalin!", - "cancel": "Batalkan" + "cancel": "Batalkan", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n} dokumen", diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index c9f8be30..1897fc80 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "Copia stringa di connessione", "menuDeleteConnection": "Elimina connessione", "toastSidebarStringCopied": "Stringa di connessione copiata!", - "cancel": "Cancellare" + "cancel": "Cancellare", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n} documenti", diff --git a/apps/web/messages/ja.json b/apps/web/messages/ja.json index 4599f6e2..a6f65675 100644 --- a/apps/web/messages/ja.json +++ b/apps/web/messages/ja.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "接続文字列をコピー", "menuDeleteConnection": "接続を削除", "toastSidebarStringCopied": "接続文字列をコピーしました!", - "cancel": "キャンセル" + "cancel": "キャンセル", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n}件のドキュメント", diff --git a/apps/web/messages/ko.json b/apps/web/messages/ko.json index ab79f390..93050d5e 100644 --- a/apps/web/messages/ko.json +++ b/apps/web/messages/ko.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "연결 문자열 복사", "menuDeleteConnection": "연결 삭제", "toastSidebarStringCopied": "연결 문자열이 복사되었습니다!", - "cancel": "취소" + "cancel": "취소", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n}개 문서", diff --git a/apps/web/messages/ms.json b/apps/web/messages/ms.json index 7c8ce8fc..7451a2ae 100644 --- a/apps/web/messages/ms.json +++ b/apps/web/messages/ms.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "Salin Rentetan Sambungan", "menuDeleteConnection": "Padam Sambungan", "toastSidebarStringCopied": "Rentetan sambungan disalin!", - "cancel": "Batal" + "cancel": "Batal", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n} dokumen", diff --git a/apps/web/messages/nb.json b/apps/web/messages/nb.json index 0b9bc1c6..9c4d0e99 100644 --- a/apps/web/messages/nb.json +++ b/apps/web/messages/nb.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "Kopier tilkoblingsstreng", "menuDeleteConnection": "Slett tilkobling", "toastSidebarStringCopied": "Tilkoblingsstreng kopiert!", - "cancel": "Avbryt" + "cancel": "Avbryt", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n} dok.", diff --git a/apps/web/messages/nl.json b/apps/web/messages/nl.json index 9a79864b..598da12b 100644 --- a/apps/web/messages/nl.json +++ b/apps/web/messages/nl.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "Verbindingsreeks kopiëren", "menuDeleteConnection": "Verbinding verwijderen", "toastSidebarStringCopied": "Verbindingsreeks gekopieerd!", - "cancel": "Annuleren" + "cancel": "Annuleren", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n} docs", diff --git a/apps/web/messages/pl.json b/apps/web/messages/pl.json index fa489bd7..1e7ee871 100644 --- a/apps/web/messages/pl.json +++ b/apps/web/messages/pl.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "Copy Connection String", "menuDeleteConnection": "Delete Connection", "toastSidebarStringCopied": "Connection string copied!", - "cancel": "Cancel" + "cancel": "Cancel", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n} docs", diff --git a/apps/web/messages/pt-BR.json b/apps/web/messages/pt-BR.json index 0ceb6043..c2203539 100644 --- a/apps/web/messages/pt-BR.json +++ b/apps/web/messages/pt-BR.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "Copiar String de Conexão", "menuDeleteConnection": "Excluir Conexão", "toastSidebarStringCopied": "String de conexão copiada!", - "cancel": "Cancelar" + "cancel": "Cancelar", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n} docs", diff --git a/apps/web/messages/pt.json b/apps/web/messages/pt.json index 653b6b10..3156666c 100644 --- a/apps/web/messages/pt.json +++ b/apps/web/messages/pt.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "Copiar String de Ligação", "menuDeleteConnection": "Eliminar Ligação", "toastSidebarStringCopied": "String de ligação copiada!", - "cancel": "Cancelar" + "cancel": "Cancelar", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n} docs", diff --git a/apps/web/messages/ru.json b/apps/web/messages/ru.json index b7200449..497a0dc0 100644 --- a/apps/web/messages/ru.json +++ b/apps/web/messages/ru.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "Копировать строку подключения", "menuDeleteConnection": "Удалить подключение", "toastSidebarStringCopied": "Строка подключения скопирована!", - "cancel": "Отмена" + "cancel": "Отмена", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n} док.", diff --git a/apps/web/messages/sv.json b/apps/web/messages/sv.json index a0829ee6..38af5011 100644 --- a/apps/web/messages/sv.json +++ b/apps/web/messages/sv.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "Kopiera anslutningssträng", "menuDeleteConnection": "Ta bort anslutning", "toastSidebarStringCopied": "Anslutningssträng kopierad!", - "cancel": "Avbryt" + "cancel": "Avbryt", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n} dok", diff --git a/apps/web/messages/tr.json b/apps/web/messages/tr.json index 2996d120..89af9cec 100644 --- a/apps/web/messages/tr.json +++ b/apps/web/messages/tr.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "Bağlantı Dizesini Kopyala", "menuDeleteConnection": "Bağlantıyı Sil", "toastSidebarStringCopied": "Bağlantı dizesi kopyalandı!", - "cancel": "İptal" + "cancel": "İptal", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n} belge", diff --git a/apps/web/messages/uk.json b/apps/web/messages/uk.json index e0d1dbbd..9f6bde87 100644 --- a/apps/web/messages/uk.json +++ b/apps/web/messages/uk.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "Копіювати рядок підключення", "menuDeleteConnection": "Видалити підключення", "toastSidebarStringCopied": "Рядок підключення скопійовано!", - "cancel": "Скасувати" + "cancel": "Скасувати", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n} документів", diff --git a/apps/web/messages/vi.json b/apps/web/messages/vi.json index fa658c3f..ad5f80f6 100644 --- a/apps/web/messages/vi.json +++ b/apps/web/messages/vi.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "Sao chép chuỗi kết nối", "menuDeleteConnection": "Xóa kết nối", "toastSidebarStringCopied": "Đã sao chép chuỗi kết nối!", - "cancel": "Hủy" + "cancel": "Hủy", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n} tài liệu", diff --git a/apps/web/messages/zh.json b/apps/web/messages/zh.json index 47cbc74c..1b20097e 100644 --- a/apps/web/messages/zh.json +++ b/apps/web/messages/zh.json @@ -1313,7 +1313,10 @@ "menuCopyConnectionString": "复制连接字符串", "menuDeleteConnection": "删除连接", "toastSidebarStringCopied": "连接字符串已复制!", - "cancel": "取消" + "cancel": "取消", + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone." }, "document": { "docsBreadcrumb": "{n} 条文档", diff --git a/apps/web/package.json b/apps/web/package.json index c552677a..ffcc8b1a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -43,6 +43,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@space-man/react-theme-animation": "^1.1.1", "@tabler/icons-react": "^3.35.0", + "@tanstack/react-virtual": "^3.14.3", "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", "ajv": "^8.17.1", diff --git a/apps/web/src/app/api/nosql/bulk-delete/route.ts b/apps/web/src/app/api/nosql/bulk-delete/route.ts index a6ff0a18..d27e5bb7 100644 --- a/apps/web/src/app/api/nosql/bulk-delete/route.ts +++ b/apps/web/src/app/api/nosql/bulk-delete/route.ts @@ -1,7 +1,9 @@ import { NextResponse } from 'next/server'; -import { MongoClient, ObjectId } from 'mongodb'; +import { ObjectId } from 'mongodb'; import { requireNosqlAuth } from '@/app/api/nosql/_auth'; +import { sanitizeError } from '@/lib/nosql-error-sanitizer'; import { validateMongoConnectionString } from '@/app/api/nosql/_mongo-safety'; +import { getMongoClient, releaseMongoClient } from '@/lib/nosql-client-pool'; export async function POST(request: Request) { const authError = await requireNosqlAuth(request); @@ -20,19 +22,21 @@ export async function POST(request: Request) { if (documentIds.length === 0) { return NextResponse.json({ error: 'No document IDs provided' }, { status: 400 }); } + if (documentIds.length > 1000) { + return NextResponse.json({ error: 'Bulk delete limited to 1000 documents per request' }, { status: 400 }); + } const objectIds: ObjectId[] = documentIds .filter((id: string) => ObjectId.isValid(id)) .map((id: string) => new ObjectId(id)); - const client = new MongoClient(connectionString); - await client.connect(); + const client = await getMongoClient(connectionString); const collection = client.db(dbName).collection(collectionName); const result = await collection.deleteMany({ _id: { $in: objectIds } }); - await client.close(); + releaseMongoClient(connectionString); return NextResponse.json({ deletedCount: result.deletedCount }); } catch (error: any) { - return NextResponse.json({ error: error.message || 'Failed to delete documents' }, { status: 500 }); + return NextResponse.json({ error: sanitizeError(error) }, { status: 500 }); } } diff --git a/apps/web/src/app/api/nosql/collection/drop/route.ts b/apps/web/src/app/api/nosql/collection/drop/route.ts index 76268198..bae42e19 100644 --- a/apps/web/src/app/api/nosql/collection/drop/route.ts +++ b/apps/web/src/app/api/nosql/collection/drop/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from 'next/server'; -import { MongoClient } from 'mongodb'; import { requireNosqlAuth } from '@/app/api/nosql/_auth'; +import { sanitizeError } from '@/lib/nosql-error-sanitizer'; import { validateMongoConnectionString } from '@/app/api/nosql/_mongo-safety'; +import { getMongoClient, releaseMongoClient } from '@/lib/nosql-client-pool'; export async function POST(request: Request) { const authError = await requireNosqlAuth(request); @@ -21,18 +22,17 @@ export async function POST(request: Request) { return NextResponse.json({ error: connectionError }, { status: 400 }); } - const client = new MongoClient(connectionString); - await client.connect(); + const client = await getMongoClient(connectionString); const db = client.db(dbName); await db.dropCollection(collectionName); - await client.close(); + releaseMongoClient(connectionString); return NextResponse.json({ success: true }); } catch (error: any) { return NextResponse.json( - { error: error.message || 'Failed to drop collection' }, + { error: sanitizeError(error) }, { status: 500 } ); } diff --git a/apps/web/src/app/api/nosql/collection/rename/route.ts b/apps/web/src/app/api/nosql/collection/rename/route.ts index d3e3368a..369dda4d 100644 --- a/apps/web/src/app/api/nosql/collection/rename/route.ts +++ b/apps/web/src/app/api/nosql/collection/rename/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from 'next/server'; -import { MongoClient } from 'mongodb'; import { requireNosqlAuth } from '@/app/api/nosql/_auth'; import { validateMongoConnectionString } from '@/app/api/nosql/_mongo-safety'; +import { getMongoClient, releaseMongoClient } from '@/lib/nosql-client-pool'; +import { sanitizeError, validateDbName, validateCollectionName } from '@/lib/nosql-error-sanitizer'; export async function POST(request: Request) { const authError = await requireNosqlAuth(request); @@ -21,18 +22,32 @@ export async function POST(request: Request) { return NextResponse.json({ error: connectionError }, { status: 400 }); } - const client = new MongoClient(connectionString); - await client.connect(); + const dbValidation = validateDbName(dbName); + if (!dbValidation.valid) { + return NextResponse.json({ error: dbValidation.error }, { status: 400 }); + } + + const collValidation = validateCollectionName(collectionName); + if (!collValidation.valid) { + return NextResponse.json({ error: collValidation.error }, { status: 400 }); + } + + const newCollValidation = validateCollectionName(newCollectionName); + if (!newCollValidation.valid) { + return NextResponse.json({ error: newCollValidation.error }, { status: 400 }); + } + + const client = await getMongoClient(connectionString); const db = client.db(dbName); await db.collection(collectionName).rename(newCollectionName); - await client.close(); + releaseMongoClient(connectionString); return NextResponse.json({ success: true }); } catch (error: any) { return NextResponse.json( - { error: error.message || 'Failed to rename collection' }, + { error: sanitizeError(error) }, { status: 500 } ); } diff --git a/apps/web/src/app/api/nosql/collections/route.ts b/apps/web/src/app/api/nosql/collections/route.ts index b4096f24..ecb6f96f 100644 --- a/apps/web/src/app/api/nosql/collections/route.ts +++ b/apps/web/src/app/api/nosql/collections/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from 'next/server'; -import { MongoClient } from 'mongodb'; import { requireNosqlAuth } from '@/app/api/nosql/_auth'; +import { sanitizeError } from '@/lib/nosql-error-sanitizer'; import { validateMongoConnectionString } from '@/app/api/nosql/_mongo-safety'; +import { getMongoClient, releaseMongoClient } from '@/lib/nosql-client-pool'; export async function GET(request: Request) { return NextResponse.json( @@ -30,8 +31,7 @@ export async function POST(request: Request) { return NextResponse.json({ error: connectionError }, { status: 400 }); } - const client = new MongoClient(connectionString); - await client.connect(); + const client = await getMongoClient(connectionString); const db = client.db(dbName); const collectionsList = await db.listCollections().toArray(); @@ -47,12 +47,12 @@ export async function POST(request: Request) { }) ); - await client.close(); + releaseMongoClient(connectionString); return NextResponse.json({ collections: collectionsWithCounts }); } catch (error: any) { return NextResponse.json( - { error: error.message || 'Failed to list collections' }, + { error: sanitizeError(error) }, { status: 500 } ); } diff --git a/apps/web/src/app/api/nosql/connect/route.ts b/apps/web/src/app/api/nosql/connect/route.ts index 1a609cd0..8b0b1cd0 100644 --- a/apps/web/src/app/api/nosql/connect/route.ts +++ b/apps/web/src/app/api/nosql/connect/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from 'next/server'; -import { MongoClient } from 'mongodb'; import { requireNosqlAuth } from '@/app/api/nosql/_auth'; +import { sanitizeError } from '@/lib/nosql-error-sanitizer'; import { validateMongoConnectionString } from '@/app/api/nosql/_mongo-safety'; +import { getMongoClient, releaseMongoClient } from '@/lib/nosql-client-pool'; export async function POST(request: Request) { const authError = await requireNosqlAuth(request); @@ -21,13 +22,11 @@ export async function POST(request: Request) { return NextResponse.json({ error: connectionError }, { status: 400 }); } - const client = new MongoClient(connectionString); - await client.connect(); + const client = await getMongoClient(connectionString); // List databases to verify connection and permissions const dbs = await client.db().admin().listDatabases(); - - await client.close(); + releaseMongoClient(connectionString); return NextResponse.json({ success: true, @@ -36,7 +35,7 @@ export async function POST(request: Request) { }); } catch (error: any) { return NextResponse.json( - { error: error.message || 'Failed to connect to MongoDB' }, + { error: sanitizeError(error) }, { status: 500 } ); } diff --git a/apps/web/src/app/api/nosql/database/drop/route.ts b/apps/web/src/app/api/nosql/database/drop/route.ts index f46455c2..bdc82531 100644 --- a/apps/web/src/app/api/nosql/database/drop/route.ts +++ b/apps/web/src/app/api/nosql/database/drop/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from 'next/server'; -import { MongoClient } from 'mongodb'; import { requireNosqlAuth } from '@/app/api/nosql/_auth'; +import { sanitizeError } from '@/lib/nosql-error-sanitizer'; import { validateMongoConnectionString } from '@/app/api/nosql/_mongo-safety'; +import { getMongoClient, releaseMongoClient } from '@/lib/nosql-client-pool'; export async function POST(request: Request) { const authError = await requireNosqlAuth(request); @@ -21,18 +22,17 @@ export async function POST(request: Request) { return NextResponse.json({ error: connectionError }, { status: 400 }); } - const client = new MongoClient(connectionString); - await client.connect(); + const client = await getMongoClient(connectionString); const db = client.db(dbName); await db.dropDatabase(); - await client.close(); + releaseMongoClient(connectionString); return NextResponse.json({ success: true }); } catch (error: any) { return NextResponse.json( - { error: error.message || 'Failed to drop database' }, + { error: sanitizeError(error) }, { status: 500 } ); } diff --git a/apps/web/src/app/api/nosql/database/rename/route.ts b/apps/web/src/app/api/nosql/database/rename/route.ts index 58a1dcb2..8555f6f4 100644 --- a/apps/web/src/app/api/nosql/database/rename/route.ts +++ b/apps/web/src/app/api/nosql/database/rename/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from 'next/server'; -import { MongoClient } from 'mongodb'; import { requireNosqlAuth } from '@/app/api/nosql/_auth'; import { validateMongoConnectionString } from '@/app/api/nosql/_mongo-safety'; +import { getMongoClient, releaseMongoClient } from '@/lib/nosql-client-pool'; +import { sanitizeError, validateDbName } from '@/lib/nosql-error-sanitizer'; export async function POST(request: Request) { const authError = await requireNosqlAuth(request); @@ -18,27 +19,25 @@ export async function POST(request: Request) { return NextResponse.json({ error: connectionError }, { status: 400 }); } - const client = new MongoClient(connectionString); - await client.connect(); + const oldNameValidation = validateDbName(oldDbName); + if (!oldNameValidation.valid) { + return NextResponse.json({ error: oldNameValidation.error }, { status: 400 }); + } + + const newNameValidation = validateDbName(newDbName); + if (!newNameValidation.valid) { + return NextResponse.json({ error: newNameValidation.error }, { status: 400 }); + } + + const client = await getMongoClient(connectionString); try { const oldDb = client.db(oldDbName); const collections = await oldDb.listCollections().toArray(); - if (collections.length === 0) { - // If no collections, just create the new DB (by inserting a dummy doc and deleting it? or just nothing) - // Actually, if there are no collections, "renaming" effectively just means ensuring the new one exists if we were to create it. - // But in MongoDB, DBs are created on demand. - // If the old DB is empty, we can't really "move" it. - // We'll just return success, effectively "doing nothing" but the UI will show the new name if we refresh? - // No, we should probably error or warn. - // But let's proceed with moving collections if they exist. - } - // Move each collection for (const collection of collections) { const collectionName = collection.name; - // Skip system collections if (collectionName.startsWith('system.')) continue; const adminDb = client.db('admin'); @@ -48,16 +47,11 @@ export async function POST(request: Request) { }); } - // The old DB will automatically disappear when empty, unless it has users/roles defined on it. - // We won't explicitly drop it to avoid deleting users/roles if they exist, - // but for a simple explorer, this is usually sufficient. - return NextResponse.json({ success: true }); } finally { - await client.close(); + releaseMongoClient(connectionString); } } catch (error: any) { - console.error('Error renaming database:', error); - return NextResponse.json({ error: error.message || 'Failed to rename database' }, { status: 500 }); + return NextResponse.json({ error: sanitizeError(error) }, { status: 500 }); } } diff --git a/apps/web/src/app/api/nosql/databases/route.ts b/apps/web/src/app/api/nosql/databases/route.ts index 1a7e896f..acec0af5 100644 --- a/apps/web/src/app/api/nosql/databases/route.ts +++ b/apps/web/src/app/api/nosql/databases/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from 'next/server'; -import { MongoClient } from 'mongodb'; import { requireNosqlAuth } from '@/app/api/nosql/_auth'; +import { sanitizeError } from '@/lib/nosql-error-sanitizer'; import { validateMongoConnectionString } from '@/app/api/nosql/_mongo-safety'; +import { getMongoClient, releaseMongoClient } from '@/lib/nosql-client-pool'; export async function GET(request: Request) { return NextResponse.json( @@ -30,17 +31,15 @@ export async function POST(request: Request) { return NextResponse.json({ error: connectionError }, { status: 400 }); } - const client = new MongoClient(connectionString); - await client.connect(); + const client = await getMongoClient(connectionString); const dbs = await client.db().admin().listDatabases(); - - await client.close(); + releaseMongoClient(connectionString); return NextResponse.json({ databases: dbs.databases }); } catch (error: any) { return NextResponse.json( - { error: error.message || 'Failed to list databases' }, + { error: sanitizeError(error) }, { status: 500 } ); } diff --git a/apps/web/src/app/api/nosql/documents/query/route.ts b/apps/web/src/app/api/nosql/documents/query/route.ts index 62f65d13..07d26095 100644 --- a/apps/web/src/app/api/nosql/documents/query/route.ts +++ b/apps/web/src/app/api/nosql/documents/query/route.ts @@ -1,7 +1,10 @@ import { NextResponse } from "next/server" -import { MongoClient, ObjectId } from "mongodb" +import { ObjectId } from "mongodb" import { requireNosqlAuth } from "@/app/api/nosql/_auth" import { validateMongoConnectionString } from "@/app/api/nosql/_mongo-safety" +import { getMongoClient, releaseMongoClient } from "@/lib/nosql-client-pool" +import { validateAggregationPipeline } from "@/lib/nosql-aggregation-validator" +import { sanitizeError } from "@/lib/nosql-error-sanitizer" export async function POST(request: Request) { const authError = await requireNosqlAuth(request) @@ -59,12 +62,19 @@ export async function POST(request: Request) { query = convertObjectIds(query) isAggregation = Array.isArray(query) + + // Validate aggregation pipeline for security + if (isAggregation) { + const validation = validateAggregationPipeline(query) + if (!validation.valid) { + return NextResponse.json({ error: validation.error }, { status: 400 }) + } + } } catch { return NextResponse.json({ error: "Invalid JSON in query parameter" }, { status: 400 }) } - const client = new MongoClient(connectionString) - await client.connect() + const client = await getMongoClient(connectionString) try { const db = client.db(dbName) @@ -95,11 +105,11 @@ export async function POST(request: Request) { return NextResponse.json({ documents, total }) } finally { - await client.close() + releaseMongoClient(connectionString) } } catch (error: any) { return NextResponse.json( - { error: error.message || "Failed to fetch documents" }, + { error: sanitizeError(error) }, { status: 500 } ) } diff --git a/apps/web/src/app/api/nosql/documents/route.ts b/apps/web/src/app/api/nosql/documents/route.ts index eb297229..ae1a8622 100644 --- a/apps/web/src/app/api/nosql/documents/route.ts +++ b/apps/web/src/app/api/nosql/documents/route.ts @@ -1,7 +1,9 @@ import { NextResponse } from 'next/server'; -import { MongoClient, ObjectId } from 'mongodb'; +import { ObjectId } from 'mongodb'; import { requireNosqlAuth } from '@/app/api/nosql/_auth'; +import { sanitizeError } from '@/lib/nosql-error-sanitizer'; import { validateMongoConnectionString } from '@/app/api/nosql/_mongo-safety'; +import { getMongoClient, releaseMongoClient } from '@/lib/nosql-client-pool'; export async function GET(request: Request) { return NextResponse.json( @@ -30,8 +32,7 @@ export async function POST(request: Request) { return NextResponse.json({ error: connectionError }, { status: 400 }); } - const client = new MongoClient(connectionString); - await client.connect(); + const client = await getMongoClient(connectionString); const db = client.db(dbName); const collection = db.collection(collectionName); @@ -52,13 +53,12 @@ export async function POST(request: Request) { } const result = await collection.insertOne(document); - - await client.close(); + releaseMongoClient(connectionString); return NextResponse.json({ result }); } catch (error: any) { return NextResponse.json( - { error: error.message || 'Failed to insert document' }, + { error: sanitizeError(error) }, { status: 500 } ); } @@ -82,8 +82,7 @@ export async function PUT(request: Request) { return NextResponse.json({ error: connectionError }, { status: 400 }); } - const client = new MongoClient(connectionString); - await client.connect(); + const client = await getMongoClient(connectionString); const db = client.db(dbName); const collection = db.collection(collectionName); @@ -101,13 +100,12 @@ export async function PUT(request: Request) { delete update._id; const result = await collection.updateOne(filter, { $set: update }); - - await client.close(); + releaseMongoClient(connectionString); return NextResponse.json({ result }); } catch (error: any) { return NextResponse.json( - { error: error.message || 'Failed to update document' }, + { error: sanitizeError(error) }, { status: 500 } ); } @@ -131,8 +129,7 @@ export async function DELETE(request: Request) { return NextResponse.json({ error: connectionError }, { status: 400 }); } - const client = new MongoClient(connectionString); - await client.connect(); + const client = await getMongoClient(connectionString); const db = client.db(dbName); const collection = db.collection(collectionName); @@ -147,13 +144,12 @@ export async function DELETE(request: Request) { } const result = await collection.deleteOne(filter); - - await client.close(); + releaseMongoClient(connectionString); return NextResponse.json({ result }); } catch (error: any) { return NextResponse.json( - { error: error.message || 'Failed to delete document' }, + { error: sanitizeError(error) }, { status: 500 } ); } diff --git a/apps/web/src/app/api/nosql/import/route.ts b/apps/web/src/app/api/nosql/import/route.ts index 5f728acd..5b62ab68 100644 --- a/apps/web/src/app/api/nosql/import/route.ts +++ b/apps/web/src/app/api/nosql/import/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from 'next/server'; -import { MongoClient } from 'mongodb'; import { requireNosqlAuth } from '@/app/api/nosql/_auth'; +import { sanitizeError } from '@/lib/nosql-error-sanitizer'; import { validateMongoConnectionString } from '@/app/api/nosql/_mongo-safety'; +import { getMongoClient, releaseMongoClient } from '@/lib/nosql-client-pool'; export async function POST(request: Request) { const authError = await requireNosqlAuth(request); @@ -24,14 +25,13 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Maximum 10,000 documents per import' }, { status: 400 }); } - const client = new MongoClient(connectionString); - await client.connect(); + const client = await getMongoClient(connectionString); const collection = client.db(dbName).collection(collectionName); const result = await collection.insertMany(documents, { ordered: false }); - await client.close(); + releaseMongoClient(connectionString); return NextResponse.json({ insertedCount: result.insertedCount }); } catch (error: any) { - return NextResponse.json({ error: error.message || 'Failed to import documents' }, { status: 500 }); + return NextResponse.json({ error: sanitizeError(error) }, { status: 500 }); } } diff --git a/apps/web/src/app/api/nosql/indexes/list/route.ts b/apps/web/src/app/api/nosql/indexes/list/route.ts index c3b565f0..a0ff0147 100644 --- a/apps/web/src/app/api/nosql/indexes/list/route.ts +++ b/apps/web/src/app/api/nosql/indexes/list/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server" -import { MongoClient } from "mongodb" import { requireNosqlAuth } from "@/app/api/nosql/_auth" +import { sanitizeError } from '@/lib/nosql-error-sanitizer'; import { validateMongoConnectionString } from "@/app/api/nosql/_mongo-safety" +import { getMongoClient, releaseMongoClient } from "@/lib/nosql-client-pool" export async function POST(request: Request) { const authError = await requireNosqlAuth(request) @@ -19,8 +20,7 @@ export async function POST(request: Request) { return NextResponse.json({ error: connectionError }, { status: 400 }) } - const client = new MongoClient(connectionString) - await client.connect() + const client = await getMongoClient(connectionString) try { const collection = client.db(dbName).collection(collectionName) @@ -34,7 +34,7 @@ export async function POST(request: Request) { } return NextResponse.json({ indexes, totalIndexSize }) } finally { - await client.close() + releaseMongoClient(connectionString) } } catch (error: any) { return NextResponse.json( diff --git a/apps/web/src/app/api/nosql/indexes/route.ts b/apps/web/src/app/api/nosql/indexes/route.ts index df16b555..af952d3c 100644 --- a/apps/web/src/app/api/nosql/indexes/route.ts +++ b/apps/web/src/app/api/nosql/indexes/route.ts @@ -1,12 +1,13 @@ import { NextResponse } from 'next/server'; -import { MongoClient } from 'mongodb'; import { requireNosqlAuth } from '@/app/api/nosql/_auth'; +import { sanitizeError } from '@/lib/nosql-error-sanitizer'; import { validateMongoConnectionString } from '@/app/api/nosql/_mongo-safety'; +import { getMongoClient, releaseMongoClient } from '@/lib/nosql-client-pool'; export async function GET(request: Request) { return NextResponse.json( { - error: 'GET is disabled for security. Use POST /api/nosql/indexes/list with a JSON body.', + error: 'GET is disabled for security. Use POST /api/nosql/indexes with a JSON body.', }, { status: 405 } ); @@ -25,14 +26,13 @@ export async function POST(request: Request) { if (connectionError) { return NextResponse.json({ error: connectionError }, { status: 400 }); } - const client = new MongoClient(connectionString); - await client.connect(); + const client = await getMongoClient(connectionString); const collection = client.db(dbName).collection(collectionName); const indexName = await collection.createIndex(keys, options || {}); - await client.close(); + releaseMongoClient(connectionString); return NextResponse.json({ indexName }); } catch (error: any) { - return NextResponse.json({ error: error.message || 'Failed to create index' }, { status: 500 }); + return NextResponse.json({ error: sanitizeError(error) }, { status: 500 }); } } @@ -49,13 +49,12 @@ export async function DELETE(request: Request) { if (connectionError) { return NextResponse.json({ error: connectionError }, { status: 400 }); } - const client = new MongoClient(connectionString); - await client.connect(); + const client = await getMongoClient(connectionString); const collection = client.db(dbName).collection(collectionName); await collection.dropIndex(indexName); - await client.close(); + releaseMongoClient(connectionString); return NextResponse.json({ success: true }); } catch (error: any) { - return NextResponse.json({ error: error.message || 'Failed to drop index' }, { status: 500 }); + return NextResponse.json({ error: sanitizeError(error) }, { status: 500 }); } } diff --git a/apps/web/src/app/api/nosql/schema/route.ts b/apps/web/src/app/api/nosql/schema/route.ts index 3d73300c..3cf33f93 100644 --- a/apps/web/src/app/api/nosql/schema/route.ts +++ b/apps/web/src/app/api/nosql/schema/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from 'next/server'; -import { MongoClient } from 'mongodb'; import { requireNosqlAuth } from '@/app/api/nosql/_auth'; +import { sanitizeError } from '@/lib/nosql-error-sanitizer'; import { validateMongoConnectionString } from '@/app/api/nosql/_mongo-safety'; +import { getMongoClient, releaseMongoClient } from '@/lib/nosql-client-pool'; export async function GET(request: Request) { return NextResponse.json( @@ -28,12 +29,11 @@ export async function POST(request: Request) { return NextResponse.json({ error: connectionError }, { status: 400 }); } - const client = new MongoClient(connectionString); - await client.connect(); + const client = await getMongoClient(connectionString); const collection = client.db(dbName).collection(collectionName); const docs = await collection.aggregate([{ $sample: { size: sampleSize } }]).toArray(); - await client.close(); + releaseMongoClient(connectionString); if (docs.length === 0) { return NextResponse.json({ fields: [], sampleSize: 0 }); @@ -89,6 +89,6 @@ export async function POST(request: Request) { return NextResponse.json({ fields, sampleSize: docs.length }); } catch (error: any) { - return NextResponse.json({ error: error.message || 'Failed to analyze schema' }, { status: 500 }); + return NextResponse.json({ error: sanitizeError(error) }, { status: 500 }); } } diff --git a/apps/web/src/app/app/nosql-explorer/page.tsx b/apps/web/src/app/app/nosql-explorer/page.tsx index 3abf69db..d8a2db9a 100644 --- a/apps/web/src/app/app/nosql-explorer/page.tsx +++ b/apps/web/src/app/app/nosql-explorer/page.tsx @@ -14,12 +14,11 @@ import { useVaultGuard } from "@/hooks/use-vault-guard"; import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder"; import { getConnections } from "@/components/nosql-explorer/connection-service"; import { cn } from "@/lib/utils"; -import { IconDatabase, IconServer, IconBrandMongodb, IconSearch, IconPlus, IconArrowLeft } from "@tabler/icons-react"; +import { IconDatabase, IconServer, IconBrandMongodb, IconSearch, IconPlus, IconArrowLeft, IconMenu2 } from "@tabler/icons-react"; import { Button } from "@/components/ui/button"; import { useMediaQuery } from "@/hooks/use-media-query"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; -import { Menu } from "lucide-react"; import { ResponsiveModal, ResponsiveModalBody, @@ -247,12 +246,15 @@ export default function NoSQLExplorerPage() { }; const handleTabClose = (tabId: string) => { - setTabs((prev) => prev.filter((t) => t.id !== tabId)); - if (activeTabId === tabId) { - const index = tabs.findIndex((t) => t.id === tabId); - const newActiveTab = tabs[index - 1] || tabs[index + 1]; - setActiveTabId(newActiveTab ? newActiveTab.id : null); - } + setTabs((prev) => { + const next = prev.filter((t) => t.id !== tabId); + if (activeTabId === tabId) { + const idx = prev.findIndex((t) => t.id === tabId); + const nextTab = prev[idx - 1] ?? prev[idx + 1] ?? null; + setActiveTabId(nextTab?.id ?? null); + } + return next; + }); }; const handleCloseAllTabs = () => { @@ -268,36 +270,34 @@ export default function NoSQLExplorerPage() { const activeTab = tabs.find((t) => t.id === activeTabId); - // We need to get the connection string for the active tab to perform actions - // Since we don't store it in the tab (security/size), we might need to fetch it or pass it. - // For simplicity, let's assume we can re-fetch it or store it in the tab. - // Storing in tab is easiest for now. - // Wait, we have connectionId. We can fetch it from a cache or just store it in tab. - // Let's store connectionString in tab for now to make it work easily, - // but strictly speaking we should look it up. - // Actually, let's just fetch the connection string again using getConnections if needed, - // OR just store it in the tab. Storing in tab is fine for client-side state. - // I'll update ExplorerTab to include connectionString in a separate hidden field or just use the one passed to fetchDocuments. - // Let's update fetchDocumentsForTab to take connectionString. - // But for refresh/insert/update/delete we need it too. - // Let's add connectionString to ExplorerTab for convenience. + const connectionCacheRef = useRef>(new Map()); + + const getConnectionForTab = useCallback(async (tab: ExplorerTab) => { + const cached = connectionCacheRef.current.get(tab.connectionId); + if (cached) return cached; + if (!user || !encryptionKey) throw new Error("Not authenticated"); + const connections = await getConnections(user.uid, encryptionKey); + connections.forEach(c => connectionCacheRef.current.set(c.id, c)); + const conn = connections.find(c => c.id === tab.connectionId); + if (!conn) throw new Error("Connection not found — try refreshing the sidebar"); + return conn; + }, [user, encryptionKey]); const handleRefresh = async () => { - if (activeTab && user && encryptionKey) { - const connections = await getConnections(user.uid, encryptionKey); - const conn = connections.find(c => c.id === activeTab.connectionId); - if (conn) { + if (activeTab) { + try { + const conn = await getConnectionForTab(activeTab); fetchDocumentsForTab(activeTab, conn.connectionString); + } catch (e: any) { + updateTab(activeTab.id, { loading: false, error: e.message }); } } }; const handleInsert = async (doc: any) => { - if (!activeTab || !user || !encryptionKey) return; + if (!activeTab) return; try { - const connections = await getConnections(user.uid, encryptionKey); - const conn = connections.find(c => c.id === activeTab.connectionId); - if (!conn) throw new Error("Connection not found"); + const conn = await getConnectionForTab(activeTab); const res = await fetch("/api/nosql/documents", { method: "POST", @@ -318,11 +318,9 @@ export default function NoSQLExplorerPage() { }; const handleUpdate = async (id: string, update: any) => { - if (!activeTab || !user || !encryptionKey) return; + if (!activeTab) return; try { - const connections = await getConnections(user.uid, encryptionKey); - const conn = connections.find(c => c.id === activeTab.connectionId); - if (!conn) throw new Error("Connection not found"); + const conn = await getConnectionForTab(activeTab); const res = await fetch("/api/nosql/documents", { method: "PUT", @@ -349,12 +347,10 @@ export default function NoSQLExplorerPage() { const confirmDelete = async () => { const id = deleteConfirmation.documentId; - if (!activeTab || !user || !id) return; + if (!activeTab || !id) return; try { - const connections = await getConnections(user.uid, encryptionKey!); - const conn = connections.find(c => c.id === activeTab.connectionId); - if (!conn) throw new Error("Connection not found"); + const conn = await getConnectionForTab(activeTab); const res = await fetch("/api/nosql/documents", { method: "DELETE", @@ -415,10 +411,8 @@ export default function NoSQLExplorerPage() { }; const handleBulkDelete = async (ids: string[]) => { - if (!activeTab || !user || !encryptionKey) return; - const connections = await getConnections(user.uid, encryptionKey); - const conn = connections.find(c => c.id === activeTab.connectionId); - if (!conn) throw new Error("Connection not found"); + if (!activeTab) return; + const conn = await getConnectionForTab(activeTab); const res = await fetch("/api/nosql/bulk-delete", { method: "POST", @@ -436,10 +430,8 @@ export default function NoSQLExplorerPage() { }; const handleImport = async (documents: any[]) => { - if (!activeTab || !user || !encryptionKey) return; - const connections = await getConnections(user.uid, encryptionKey); - const conn = connections.find(c => c.id === activeTab.connectionId); - if (!conn) throw new Error("Connection not found"); + if (!activeTab) return; + const conn = await getConnectionForTab(activeTab); const res = await fetch("/api/nosql/import", { method: "POST", @@ -457,10 +449,8 @@ export default function NoSQLExplorerPage() { }; const handleLoadSchema = useCallback(async () => { - if (!activeTab || !user || !encryptionKey) throw new Error("No active tab"); - const connections = await getConnections(user.uid, encryptionKey); - const conn = connections.find(c => c.id === activeTab.connectionId); - if (!conn) throw new Error("Connection not found"); + if (!activeTab) throw new Error("No active tab"); + const conn = await getConnectionForTab(activeTab); const res = await fetch("/api/nosql/schema", { method: "POST", @@ -474,13 +464,11 @@ export default function NoSQLExplorerPage() { const data = await res.json(); if (!res.ok) throw new Error(data.error); return data; - }, [activeTab?.id, user, encryptionKey]); + }, [activeTab, getConnectionForTab]); const handleLoadIndexes = useCallback(async () => { - if (!activeTab || !user || !encryptionKey) throw new Error("No active tab"); - const connections = await getConnections(user.uid, encryptionKey); - const conn = connections.find(c => c.id === activeTab.connectionId); - if (!conn) throw new Error("Connection not found"); + if (!activeTab) throw new Error("No active tab"); + const conn = await getConnectionForTab(activeTab); const res = await fetch("/api/nosql/indexes/list", { method: "POST", @@ -494,13 +482,11 @@ export default function NoSQLExplorerPage() { const data = await res.json(); if (!res.ok) throw new Error(data.error); return data; - }, [activeTab?.id, user, encryptionKey]); + }, [activeTab, getConnectionForTab]); const handleDropIndex = async (indexName: string) => { - if (!activeTab || !user || !encryptionKey) return; - const connections = await getConnections(user.uid, encryptionKey); - const conn = connections.find(c => c.id === activeTab.connectionId); - if (!conn) throw new Error("Connection not found"); + if (!activeTab) return; + const conn = await getConnectionForTab(activeTab); const res = await fetch("/api/nosql/indexes", { method: "DELETE", @@ -517,10 +503,8 @@ export default function NoSQLExplorerPage() { }; const handleCreateIndex = async (keys: Record, options: Record) => { - if (!activeTab || !user || !encryptionKey) return; - const connections = await getConnections(user.uid, encryptionKey); - const conn = connections.find(c => c.id === activeTab.connectionId); - if (!conn) throw new Error("Connection not found"); + if (!activeTab) return; + const conn = await getConnectionForTab(activeTab); const res = await fetch("/api/nosql/indexes", { method: "POST", @@ -538,18 +522,18 @@ export default function NoSQLExplorerPage() { }; const performFetch = async (tab: ExplorerTab) => { - if (user && encryptionKey) { - const connections = await getConnections(user.uid, encryptionKey); - const conn = connections.find(c => c.id === tab.connectionId); - if (conn) { - fetchDocumentsForTab(tab, conn.connectionString); - } + try { + const conn = await getConnectionForTab(tab); + fetchDocumentsForTab(tab, conn.connectionString); + } catch (e: any) { + updateTab(tab.id, { loading: false, error: e.message }); } }; // Auto-fetch active tab once after hydration — persisted tabs no longer carry // documents (would blow the localStorage quota), so we lazily refetch on activate. const autoFetchedTabsRef = useRef>(new Set()); + useEffect(() => { if (!isInitialized || !isUnlocked || !user || !encryptionKey || !activeTabId) return; const tab = tabs.find((t) => t.id === activeTabId); @@ -653,6 +637,10 @@ export default function NoSQLExplorerPage() { onSelectCollection={handleSelectCollection} onRefresh={() => { /* Sidebar handles its own refresh */ }} onAddConnection={() => setIsConnectionDialogOpen(true)} + onConnectionsLoaded={(conns) => { + connectionCacheRef.current.clear(); + conns.forEach(c => connectionCacheRef.current.set(c.id, c)); + }} />
{ + connectionCacheRef.current.clear(); + conns.forEach(c => connectionCacheRef.current.set(c.id, c)); + }} /> @@ -694,7 +686,7 @@ export default function NoSQLExplorerPage() { onClick={() => setMobileSidebarOpen(true)} aria-label="Open collections" > - +
{activeTab ? ( @@ -836,6 +828,7 @@ export default function NoSQLExplorerPage() { onConnect={async () => { setIsConnectionDialogOpen(false); setHasConnections(true); + connectionCacheRef.current.clear(); toast.success(t("toastConnectionAdded")); }} loading={false} diff --git a/apps/web/src/components/nosql-explorer/connection-form.tsx b/apps/web/src/components/nosql-explorer/connection-form.tsx index 463b6e66..32cfbc43 100644 --- a/apps/web/src/components/nosql-explorer/connection-form.tsx +++ b/apps/web/src/components/nosql-explorer/connection-form.tsx @@ -14,6 +14,7 @@ import { useMasterKeyStore } from "@/store/master-key-store"; import { toast } from "sonner"; import { ScrollArea } from "@/components/ui/scroll-area"; import { formatDistanceToNow } from "date-fns"; +import type { Locale } from "date-fns"; import { cn } from "@/lib/utils"; import { useTranslations, useLocale } from "next-intl"; import { af, ar, ca, cs as csLocale, da, de, el, enUS, es, faIR, fr as frLocale, ms, nb, nl, pt, zhCN } from "date-fns/locale"; @@ -27,38 +28,11 @@ interface ConnectionFormProps { export function ConnectionForm({ onConnect, loading, error }: ConnectionFormProps) { const t = useTranslations("NoSqlExplorer.connection"); const locale = useLocale(); - const dateLocale = - locale === "fr" - ? frLocale - : locale === "es" - ? es - : locale === "ar" - ? ar - : locale === "ca" - ? ca - : locale === "zh" - ? zhCN - : locale === "cs" - ? csLocale - : locale === "el" - ? el - : locale === "de" - ? de - : locale === "da" - ? da - : locale === "af" - ? af - : locale === "fa" - ? faIR - : locale === "ms" - ? ms - : locale === "nb" - ? nb - : locale === "nl" - ? nl - : locale === "pt" - ? pt - : enUS; + const DATE_LOCALE_MAP: Record = { + fr: frLocale, es, ar, ca, zh: zhCN, cs: csLocale, + el, de, da, af, fa: faIR, ms, nb, nl, pt, + }; + const dateLocale = DATE_LOCALE_MAP[locale] ?? enUS; const [connectionString, setConnectionString] = useState(""); const [name, setName] = useState("My Connection"); const [savedConnections, setSavedConnections] = useState([]); diff --git a/apps/web/src/components/nosql-explorer/document-view.tsx b/apps/web/src/components/nosql-explorer/document-view.tsx index 774fc9ec..a0fbded3 100644 --- a/apps/web/src/components/nosql-explorer/document-view.tsx +++ b/apps/web/src/components/nosql-explorer/document-view.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { Document } from "./types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -409,6 +410,15 @@ export function DocumentView({ const [indexesError, setIndexesError] = useState(null); const { theme } = useTheme(); + const tableContainerRef = useRef(null); + + const rowVirtualizer = useVirtualizer({ + count: documents.length, + getScrollElement: () => tableContainerRef.current, + estimateSize: () => 48, + overscan: 10, + }); + const openImportFromChooser = () => { setIsImportExportChooserOpen(false); setIsImportDialogOpen(true); @@ -420,7 +430,14 @@ export function DocumentView({ }; useEffect(() => { - setJsonViewContent(JSON.stringify(documents, null, 2)); + if (documents.length === 0) { + setJsonViewContent("[]"); + return; + } + const id = setTimeout(() => { + setJsonViewContent(JSON.stringify(documents, null, 2)); + }, 0); + return () => clearTimeout(id); }, [documents]); // Clear selection when documents change @@ -573,19 +590,27 @@ export function DocumentView({ document.body.style.cursor = 'col-resize'; }; - const fields = Array.from(new Set(documents.flatMap(Object.keys))).filter(k => k !== "_id"); - const allFields = ["_id", ...fields]; - const totalPages = Math.ceil(total / limit) || 1; - const isAllSelected = documents.length > 0 && selectedIds.size === documents.length; - const isIndeterminate = selectedIds.size > 0 && selectedIds.size < documents.length; - - const isFilterActive = (() => { + const fields = useMemo( + () => Array.from(new Set(documents.flatMap(Object.keys))).filter(k => k !== "_id"), + [documents] + ); + const allFields = useMemo(() => ["_id", ...fields], [fields]); + const totalPages = useMemo(() => Math.ceil(total / limit) || 1, [total, limit]); + const isAllSelected = useMemo( + () => documents.length > 0 && selectedIds.size === documents.length, + [selectedIds, documents] + ); + const isIndeterminate = useMemo( + () => selectedIds.size > 0 && selectedIds.size < documents.length, + [selectedIds, documents] + ); + const isFilterActive = useMemo(() => { try { const q = searchQuery?.trim(); if (!q || q === "{}") return false; return Object.keys(JSON.parse(q)).length > 0; } catch { return false; } - })(); + }, [searchQuery]); const showSelectMode = viewMode === 'table' && !!onBulkDelete; @@ -798,7 +823,7 @@ export function DocumentView({ /> ) : loading ? (
- +
@@ -924,18 +949,19 @@ export function DocumentView({ ) : ( /* Table view */ -
- +
+
{showSelectMode && ( )} @@ -964,12 +990,25 @@ export function DocumentView({ - - {documents.map((doc, index) => { + + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const doc = documents[virtualRow.index]; + const index = virtualRow.index; const isSelected = selectedIds.has(doc._id); return ( void; onRefresh: () => void; onAddConnection: () => void; + onConnectionsLoaded?: (connections: SavedConnection[]) => void; width?: number; } @@ -45,6 +56,7 @@ export function ExplorerSidebar({ onSelectCollection, onRefresh, onAddConnection, + onConnectionsLoaded, width = 256, }: ExplorerSidebarProps) { const t = useTranslations("NoSqlExplorer.sidebar"); @@ -59,6 +71,15 @@ export function ExplorerSidebar({ // Dialog states const [renameCollectionDialog, setRenameCollectionDialog] = useState<{ open: boolean, connection: SavedConnection | null, dbName: string, collectionName: string, newName: string }>({ open: false, connection: null, dbName: "", collectionName: "", newName: "" }); const [renameDatabaseDialog, setRenameDatabaseDialog] = useState<{ open: boolean, connection: SavedConnection | null, dbName: string, newName: string }>({ open: false, connection: null, dbName: "", newName: "" }); + const [deleteConnDialog, setDeleteConnDialog] = useState<{ open: boolean; index: number | null }>({ + open: false, index: null, + }); + const [dropDbDialog, setDropDbDialog] = useState<{ open: boolean; connIndex: number | null; dbName: string }>({ + open: false, connIndex: null, dbName: "", + }); + const [dropCollDialog, setDropCollDialog] = useState<{ open: boolean; connIndex: number | null; dbName: string; collectionName: string }>({ + open: false, connIndex: null, dbName: "", collectionName: "", + }); useEffect(() => { if (user && encryptionKey) { @@ -92,6 +113,7 @@ export function ExplorerSidebar({ }); setConnections(newConnections); + onConnectionsLoaded?.(saved); // Trigger refresh for expanded connections to load databases newConnections.forEach((node, index) => { @@ -247,22 +269,34 @@ export function ExplorerSidebar({ }; const handleDeleteConnection = async (index: number) => { + setDeleteConnDialog({ open: true, index }); + return; + }; + + const confirmDeleteConnection = async () => { + const index = deleteConnDialog.index; + if (index === null) return; const node = connections[index]; if (!user || !node.connection.id) return; - if (!confirm(t("confirmDeleteConnection", { name: node.connection.name }))) return; - try { await deleteConnection(user.uid, node.connection.id); setConnections(prev => prev.filter((_, i) => i !== index)); toast.success(t("toastDeleted")); } catch (error) { toast.error(t("toastDeleteConnFail")); + } finally { + setDeleteConnDialog({ open: false, index: null }); } }; const handleDropDatabase = async (connIndex: number, dbName: string) => { - if (!confirm(t("confirmDropDb", { name: dbName }))) return; + setDropDbDialog({ open: true, connIndex, dbName }); + return; + }; + const confirmDropDatabase = async () => { + const { connIndex, dbName } = dropDbDialog; + if (connIndex === null) return; const node = connections[connIndex]; try { const res = await backendFetch("/api/nosql/database/drop", { @@ -272,17 +306,23 @@ export function ExplorerSidebar({ }); const data = await res.json(); if (!res.ok) throw new Error(data.error); - toast.success(t("toastDbDropped", { name: dbName })); refreshDatabases(connIndex); } catch (error: any) { toast.error(error.message); + } finally { + setDropDbDialog({ open: false, connIndex: null, dbName: "" }); } }; const handleDropCollection = async (connIndex: number, dbName: string, collectionName: string) => { - if (!confirm(t("confirmDropCollection", { name: collectionName }))) return; + setDropCollDialog({ open: true, connIndex, dbName, collectionName }); + return; + }; + const confirmDropCollection = async () => { + const { connIndex, dbName, collectionName } = dropCollDialog; + if (connIndex === null) return; const node = connections[connIndex]; try { const res = await backendFetch("/api/nosql/collection/drop", { @@ -292,11 +332,12 @@ export function ExplorerSidebar({ }); const data = await res.json(); if (!res.ok) throw new Error(data.error); - toast.success(t("toastCollectionDropped", { name: collectionName })); refreshCollections(connIndex, dbName); } catch (error: any) { toast.error(error.message); + } finally { + setDropCollDialog({ open: false, connIndex: null, dbName: "", collectionName: "" }); } }; @@ -525,7 +566,7 @@ export function ExplorerSidebar({ -

{node.connection.connectionString}

+

{node.connection.connectionString.replace(/:([^@]+)@/, ":****@")}

{node.error &&

{node.error}

}
@@ -730,6 +771,51 @@ export function ExplorerSidebar({ + + setDeleteConnDialog(prev => ({ ...prev, open }))}> + + + {t("confirmDeleteConnection", { name: deleteConnDialog.index !== null ? connections[deleteConnDialog.index]?.connection.name : "" })} + {t("confirmDeleteConnectionDesc")} + + + {t("cancel")} + + {t("menuDeleteConnection")} + + + + + + setDropDbDialog(prev => ({ ...prev, open }))}> + + + {t("confirmDropDb", { name: dropDbDialog.dbName })} + {t("confirmDropDbDesc")} + + + {t("cancel")} + + {t("dropDatabase")} + + + + + + setDropCollDialog(prev => ({ ...prev, open }))}> + + + {t("confirmDropCollection", { name: dropCollDialog.collectionName })} + {t("confirmDropCollectionDesc")} + + + {t("cancel")} + + {t("dropCollection")} + + + + ); } diff --git a/apps/web/src/lib/nosql-aggregation-validator.ts b/apps/web/src/lib/nosql-aggregation-validator.ts new file mode 100644 index 00000000..4295afa2 --- /dev/null +++ b/apps/web/src/lib/nosql-aggregation-validator.ts @@ -0,0 +1,85 @@ +// Allowed aggregation pipeline stages +const ALLOWED_STAGES = new Set([ + "$addFields", + "$bucket", + "$bucketAuto", + "$changeStream", + "$collStats", + "$count", + "$densify", + "$documents", + "$facet", + "$fill", + "$geoNear", + "$group", + "$indexStats", + "$limit", + "$listSessions", + "$lookup", + "$match", + "$merge", + "$out", + "$planCacheStats", + "$project", + "$redact", + "$replaceRoot", + "$replaceWith", + "$sample", + "$search", + "$searchMeta", + "$setWindowFields", + "$skip", + "$sort", + "$sortByCount", + "$unionWith", + "$unwind", +]) + +// Blocked stages that can execute arbitrary code +const BLOCKED_STAGES = new Set(["$where", "$function", "$accumulator", "$eval"]) + +/** + * Validates an aggregation pipeline to prevent code injection attacks. + * - Blocks $where, $function, $accumulator, $eval + * - Validates all stages are known + * - Validates stage structure is an array of objects + */ +export function validateAggregationPipeline(pipeline: unknown): { valid: boolean; error?: string } { + if (!Array.isArray(pipeline)) { + return { valid: false, error: "Pipeline must be an array of stages" } + } + + if (pipeline.length === 0) { + return { valid: false, error: "Pipeline cannot be empty" } + } + + if (pipeline.length > 100) { + return { valid: false, error: "Pipeline cannot have more than 100 stages" } + } + + for (let i = 0; i < pipeline.length; i++) { + const stage = pipeline[i] + + if (!stage || typeof stage !== "object" || Array.isArray(stage)) { + return { valid: false, error: `Stage ${i} must be a non-array object` } + } + + const stageName = Object.keys(stage)[0] + if (!stageName) { + return { valid: false, error: `Stage ${i} is empty` } + } + + if (BLOCKED_STAGES.has(stageName)) { + return { + valid: false, + error: `Stage ${stageName} is not allowed for security reasons`, + } + } + + if (!ALLOWED_STAGES.has(stageName)) { + return { valid: false, error: `Unknown aggregation stage: ${stageName}` } + } + } + + return { valid: true } +} diff --git a/apps/web/src/lib/nosql-client-pool.ts b/apps/web/src/lib/nosql-client-pool.ts new file mode 100644 index 00000000..10354d48 --- /dev/null +++ b/apps/web/src/lib/nosql-client-pool.ts @@ -0,0 +1,148 @@ +import { MongoClient } from "mongodb" + +interface PooledConnection { + client: MongoClient + lastUsed: number + refCount: number +} + +class MongoClientPool { + private static instance: MongoClientPool + private pool: Map = new Map() + private connecting: Map> = new Map() + private cleanupInterval: NodeJS.Timeout | null = null + private readonly maxPoolSize = 5 + private readonly idleTimeoutMs = 300000 // 5 minutes + private readonly connectTimeoutMs = 10000 // 10 second connection timeout + + private constructor() { + this.startCleanupInterval() + } + + static getInstance(): MongoClientPool { + if (!MongoClientPool.instance) { + MongoClientPool.instance = new MongoClientPool() + } + return MongoClientPool.instance + } + + async getClient(connectionString: string): Promise { + // Return existing pooled client if available + const existing = this.pool.get(connectionString) + if (existing) { + existing.lastUsed = Date.now() + existing.refCount++ + return existing.client + } + + // If already connecting, wait for that promise to complete + const pendingConnection = this.connecting.get(connectionString) + if (pendingConnection) { + const client = await pendingConnection + const pooled = this.pool.get(connectionString) + if (pooled) { + pooled.refCount++ + pooled.lastUsed = Date.now() + } + return client + } + + // Start new connection with timeout + const connectionPromise = this.createConnection(connectionString) + this.connecting.set(connectionString, connectionPromise) + + try { + const client = await this.withTimeout(connectionPromise, this.connectTimeoutMs) + this.connecting.delete(connectionString) + return client + } catch (error) { + this.connecting.delete(connectionString) + throw error + } + } + + private async createConnection(connectionString: string): Promise { + const client = new MongoClient(connectionString, { + maxPoolSize: this.maxPoolSize, + minPoolSize: 1, + maxIdleTimeMS: this.idleTimeoutMs, + serverSelectionTimeoutMS: 5000, + socketTimeoutMS: 5000, + connectTimeoutMS: 5000, + }) + + await client.connect() + + this.pool.set(connectionString, { + client, + lastUsed: Date.now(), + refCount: 1, + }) + + return client + } + + private withTimeout(promise: Promise, ms: number): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Connection timeout after ${ms}ms`)), ms) + ), + ]) + } + + releaseClient(connectionString: string): void { + const pooled = this.pool.get(connectionString) + if (pooled) { + pooled.refCount = Math.max(0, pooled.refCount - 1) + pooled.lastUsed = Date.now() + } + } + + private startCleanupInterval(): void { + this.cleanupInterval = setInterval(() => { + const now = Date.now() + const keysToDelete: string[] = [] + + for (const [connectionString, pooled] of this.pool.entries()) { + // Close idle connections that aren't in use + if (pooled.refCount === 0 && now - pooled.lastUsed > this.idleTimeoutMs) { + keysToDelete.push(connectionString) + } + } + + for (const key of keysToDelete) { + const pooled = this.pool.get(key) + if (pooled && pooled.refCount === 0) { + pooled.client.close().catch(console.error) + this.pool.delete(key) + } + } + }, 60000) // Check every minute + } + + async closeAll(): Promise { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval) + } + + const closePromises = Array.from(this.pool.values()).map((pooled) => + pooled.client.close().catch(console.error) + ) + + await Promise.all(closePromises) + this.pool.clear() + } +} + +export const getMongoClient = async (connectionString: string): Promise => { + return MongoClientPool.getInstance().getClient(connectionString) +} + +export const releaseMongoClient = (connectionString: string): void => { + MongoClientPool.getInstance().releaseClient(connectionString) +} + +export const closeAllMongoClients = async (): Promise => { + await MongoClientPool.getInstance().closeAll() +} diff --git a/apps/web/src/lib/nosql-error-sanitizer.ts b/apps/web/src/lib/nosql-error-sanitizer.ts new file mode 100644 index 00000000..f1785766 --- /dev/null +++ b/apps/web/src/lib/nosql-error-sanitizer.ts @@ -0,0 +1,75 @@ +/** + * Sanitize error messages to prevent leaking sensitive connection string data. + * Connection strings may contain: + * - mongodb://user:password@host/db + * - mongodb+srv://user:password@cluster + * Scrub these before returning to client. + */ +export function sanitizeError(error: unknown): string { + let message = error instanceof Error ? error.message : String(error) + + // Remove connection strings in format: mongodb[+srv]://[user:password@]host[:port][/db] + message = message.replace( + /mongodb\+?srv?:\/\/[^/\s]+(\/[^\s]*)*/gi, + "mongodb://***SANITIZED***" + ) + + // Remove auth credentials in format: user:password@ + message = message.replace(/[^/\s]+:[^/\s]+@/g, "***:***@") + + // Remove any email addresses (often used as usernames) + message = message.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, "***@***") + + return message +} + +/** + * Validate database and collection names for safe characters. + * MongoDB restrictions: + * - DB names: no /, \, ., $, \0, multiple dots + * - Collection names: no \0, $ + */ +export function validateDbName(name: string): { valid: boolean; error?: string } { + if (!name || typeof name !== "string") { + return { valid: false, error: "Database name must be a non-empty string" } + } + + if (name.length > 64) { + return { valid: false, error: "Database name cannot exceed 64 characters" } + } + + // Reject forbidden characters + if (/[/\\.\0$]/.test(name)) { + return { valid: false, error: "Database name contains forbidden characters: / \\ . \\0 $" } + } + + // Reject consecutive dots + if (/\.\./.test(name)) { + return { valid: false, error: "Database name cannot contain consecutive dots" } + } + + return { valid: true } +} + +export function validateCollectionName(name: string): { valid: boolean; error?: string } { + if (!name || typeof name !== "string") { + return { valid: false, error: "Collection name must be a non-empty string" } + } + + if (name.length > 120) { + return { valid: false, error: "Collection name cannot exceed 120 characters" } + } + + // Reject null character + if (/\0/.test(name)) { + return { valid: false, error: "Collection name cannot contain null character" } + } + + // Allow system collections (starting with $) for internal use only via special flag + // Normal collections shouldn't start with $ + if (/^\$/.test(name) && !name.startsWith("$cmd")) { + return { valid: false, error: "Collection names cannot start with $ (reserved for system)" } + } + + return { valid: true } +} diff --git a/docs/superpowers/plans/2026-06-17-nosql-explorer-optimization.md b/docs/superpowers/plans/2026-06-17-nosql-explorer-optimization.md new file mode 100644 index 00000000..5d55fec6 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-nosql-explorer-optimization.md @@ -0,0 +1,1868 @@ +# NoSQL Explorer Full-Pass Optimization — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix 25 identified bugs, performance bottlenecks, UX issues, accessibility gaps, and mobile problems in the NoSQL Explorer app across 4 sprints. + +**Architecture:** Layer-by-layer: bugs → performance → UX → accessibility → mobile. Each sprint is independently shippable. All changes are confined to `apps/web/src/components/nosql-explorer/` and `apps/web/src/app/app/nosql-explorer/page.tsx`. No API or schema changes. + +**Tech Stack:** Next.js 14, React, TypeScript, Tailwind CSS, shadcn/ui, `@tabler/icons-react`, `@tanstack/react-virtual` (new), Monaco Editor, next-intl. + +## Global Constraints + +- All files are under `apps/web/src/` — all paths below are relative to that root +- Use `@tabler/icons-react` for all icons — never `lucide-react` +- Run `pnpm --filter @mydevtools/web lint` after each task — zero new warnings +- Run `pnpm --filter @mydevtools/web build` after each sprint to confirm no build regressions +- No changes to API routes, database schema, or `types.ts` unless explicitly stated +- Do not add comments to code (project convention) +- `isDesktop` = `useMediaQuery("(min-width: 768px)")` — already imported via `@/hooks/use-media-query` + +--- + +## Sprint 1 — Bug Fixes + +--- + +### Task 1: Mask password in sidebar tooltip + unify icon library + fix locale map + +**Files:** +- Modify: `components/nosql-explorer/explorer-sidebar.tsx` (line 528) +- Modify: `app/app/nosql-explorer/page.tsx` (line 22) +- Modify: `components/nosql-explorer/connection-form.tsx` (lines 19–61) + +**Interfaces:** +- Produces: nothing consumed by other tasks — isolated fixes + +- [ ] **Step 1: Confirm current broken behaviors** + + Open the app, navigate to `/app/nosql-explorer`. Add a connection with credentials in the URL (e.g. `mongodb://user:password@host:27017`). Hover over the connection name in the sidebar. Verify the tooltip shows the raw password. This is the bug we're fixing. + +- [ ] **Step 2: Fix password mask in sidebar tooltip** + + In `components/nosql-explorer/explorer-sidebar.tsx`, find line 528: + ```tsx +

{node.connection.connectionString}

+ ``` + Replace with: + ```tsx +

{node.connection.connectionString.replace(/:([^@]+)@/, ":****@")}

+ ``` + +- [ ] **Step 3: Replace lucide-react Menu icon with tabler IconMenu2** + + In `app/app/nosql-explorer/page.tsx`: + + Remove from imports (line 22): + ```tsx + import { Menu } from "lucide-react"; + ``` + + Add `IconMenu2` to the existing tabler import (line 17): + ```tsx + import { IconDatabase, IconServer, IconBrandMongodb, IconSearch, IconPlus, IconArrowLeft, IconMenu2 } from "@tabler/icons-react"; + ``` + + Find the JSX usage (~line 697): + ```tsx + + ``` + Replace with: + ```tsx + + ``` + +- [ ] **Step 4: Replace locale nested ternary with lookup map** + + In `components/nosql-explorer/connection-form.tsx`, replace lines 19–61: + + ```tsx + import { af, ar, ca, cs as csLocale, da, de, el, enUS, es, faIR, fr as frLocale, ms, nb, nl, pt, zhCN } from "date-fns/locale"; + ``` + Keep this import as-is. Then replace the entire nested ternary block (lines ~31–61) with: + ```tsx + const DATE_LOCALE_MAP: Record = { + fr: frLocale, es, ar, ca, zh: zhCN, cs: csLocale, + el, de, da, af, fa: faIR, ms, nb, nl, pt, + }; + const dateLocale = DATE_LOCALE_MAP[locale] ?? enUS; + ``` + Remove the old `const dateLocale = locale === "fr" ? frLocale : ...` block entirely. + +- [ ] **Step 5: Lint and type-check** + + ```bash + cd apps/web && pnpm lint + ``` + Expected: 0 errors, 0 warnings related to changed files. + +- [ ] **Step 6: Verify manually** + + 1. Hover over a connection with credentials in the URL — tooltip should show `mongodb://user:****@host:27017` + 2. Open browser DevTools → Network. Confirm `lucide-react` is not imported (no `Menu` chunk) + 3. Open connection form — "Last used" timestamps render correctly in non-English locales (switch locale in settings if available) + +- [ ] **Step 7: Commit** + + ```bash + git add apps/web/src/components/nosql-explorer/explorer-sidebar.tsx \ + apps/web/src/app/app/nosql-explorer/page.tsx \ + apps/web/src/components/nosql-explorer/connection-form.tsx + git commit -m "fix(nosql): mask credentials in tooltip, unify icons, simplify locale map" + ``` + +--- + +### Task 2: Replace `confirm()` dialogs with AlertDialog in ExplorerSidebar + +**Files:** +- Modify: `components/nosql-explorer/explorer-sidebar.tsx` + +**Interfaces:** +- Produces: nothing consumed by other tasks + +- [ ] **Step 1: Add AlertDialog import** + + In `components/nosql-explorer/explorer-sidebar.tsx`, add to existing shadcn imports: + ```tsx + import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + } from "@/components/ui/alert-dialog"; + ``` + +- [ ] **Step 2: Add three dialog state slots** + + Inside `ExplorerSidebar`, after the existing `renameDatabaseDialog` state (around line 61), add: + ```tsx + const [deleteConnDialog, setDeleteConnDialog] = useState<{ open: boolean; index: number | null }>({ + open: false, index: null, + }); + const [dropDbDialog, setDropDbDialog] = useState<{ open: boolean; connIndex: number | null; dbName: string }>({ + open: false, connIndex: null, dbName: "", + }); + const [dropCollDialog, setDropCollDialog] = useState<{ open: boolean; connIndex: number | null; dbName: string; collectionName: string }>({ + open: false, connIndex: null, dbName: "", collectionName: "", + }); + ``` + +- [ ] **Step 3: Replace `confirm()` in handleDeleteConnection** + + Find `handleDeleteConnection` (~line 249). Replace: + ```tsx + if (!confirm(t("confirmDeleteConnection", { name: node.connection.name }))) return; + ``` + With: + ```tsx + setDeleteConnDialog({ open: true, index }); + return; + ``` + Move the rest of the function body into a new `confirmDeleteConnection` async function: + ```tsx + const confirmDeleteConnection = async () => { + const index = deleteConnDialog.index; + if (index === null) return; + const node = connections[index]; + if (!user || !node.connection.id) return; + try { + await deleteConnection(user.uid, node.connection.id); + setConnections(prev => prev.filter((_, i) => i !== index)); + toast.success(t("toastDeleted")); + } catch (error) { + toast.error(t("toastDeleteConnFail")); + } finally { + setDeleteConnDialog({ open: false, index: null }); + } + }; + ``` + +- [ ] **Step 4: Replace `confirm()` in handleDropDatabase** + + Find `handleDropDatabase` (~line 263). Replace: + ```tsx + if (!confirm(t("confirmDropDb", { name: dbName }))) return; + ``` + With: + ```tsx + setDropDbDialog({ open: true, connIndex, dbName }); + return; + ``` + Create `confirmDropDatabase`: + ```tsx + const confirmDropDatabase = async () => { + const { connIndex, dbName } = dropDbDialog; + if (connIndex === null) return; + const node = connections[connIndex]; + try { + const res = await backendFetch("/api/nosql/database/drop", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ connectionString: node.connection.connectionString, dbName }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + toast.success(t("toastDbDropped", { name: dbName })); + refreshDatabases(connIndex); + } catch (error: any) { + toast.error(error.message); + } finally { + setDropDbDialog({ open: false, connIndex: null, dbName: "" }); + } + }; + ``` + +- [ ] **Step 5: Replace `confirm()` in handleDropCollection** + + Find `handleDropCollection` (~line 283). Replace: + ```tsx + if (!confirm(t("confirmDropCollection", { name: collectionName }))) return; + ``` + With: + ```tsx + setDropCollDialog({ open: true, connIndex, dbName, collectionName }); + return; + ``` + Create `confirmDropCollection`: + ```tsx + const confirmDropCollection = async () => { + const { connIndex, dbName, collectionName } = dropCollDialog; + if (connIndex === null) return; + const node = connections[connIndex]; + try { + const res = await backendFetch("/api/nosql/collection/drop", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ connectionString: node.connection.connectionString, dbName, collectionName }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + toast.success(t("toastCollectionDropped", { name: collectionName })); + refreshCollections(connIndex, dbName); + } catch (error: any) { + toast.error(error.message); + } finally { + setDropCollDialog({ open: false, connIndex: null, dbName: "", collectionName: "" }); + } + }; + ``` + +- [ ] **Step 6: Add three AlertDialog renders at bottom of return** + + Before the closing `` of the return, after the existing rename dialogs, add: + ```tsx + setDeleteConnDialog(prev => ({ ...prev, open }))}> + + + {t("confirmDeleteConnection", { name: deleteConnDialog.index !== null ? connections[deleteConnDialog.index]?.connection.name : "" })} + {t("confirmDeleteConnectionDesc")} + + + {t("cancel")} + + {t("menuDeleteConnection")} + + + + + + setDropDbDialog(prev => ({ ...prev, open }))}> + + + {t("confirmDropDb", { name: dropDbDialog.dbName })} + {t("confirmDropDbDesc")} + + + {t("cancel")} + + {t("dropDatabase")} + + + + + + setDropCollDialog(prev => ({ ...prev, open }))}> + + + {t("confirmDropCollection", { name: dropCollDialog.collectionName })} + {t("confirmDropCollectionDesc")} + + + {t("cancel")} + + {t("dropCollection")} + + + + + ``` + +- [ ] **Step 7: Add missing i18n keys** + + Open the English locale file (find with `find apps/web/src -name "*.json" | xargs grep -l "NoSqlExplorer" | head -3`). Add under `NoSqlExplorer.sidebar`: + ```json + "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", + "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", + "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone.", + "cancel": "Cancel" + ``` + Repeat for all other locale files (copy English values — translations can be updated later). + +- [ ] **Step 8: Lint** + + ```bash + cd apps/web && pnpm lint + ``` + Expected: 0 errors. + +- [ ] **Step 9: Verify manually** + + 1. Right-click a connection → "Delete Connection" — should open shadcn AlertDialog, not browser `confirm()` + 2. Right-click a database → "Drop Database" — same + 3. Right-click a collection → "Drop Collection" — same + 4. Press Escape on any dialog — should dismiss without action + +- [ ] **Step 10: Commit** + + ```bash + git add apps/web/src/components/nosql-explorer/explorer-sidebar.tsx + git commit -m "fix(nosql): replace confirm() dialogs with AlertDialog in sidebar" + ``` + +--- + +### Task 3: Fix indeterminate checkbox + +**Files:** +- Modify: `components/nosql-explorer/document-view.tsx` (line ~938) + +**Interfaces:** +- Produces: nothing consumed by other tasks + +- [ ] **Step 1: Locate the broken checkbox** + + In `components/nosql-explorer/document-view.tsx`, find the "Select all" checkbox in the table `
` (~line 930–940): + ```tsx + + ``` + +- [ ] **Step 2: Replace with Radix-native indeterminate API** + + Replace the entire `` with: + ```tsx + + ``` + Remove the `className` and `data-state` props — Radix handles indeterminate styling natively. + +- [ ] **Step 3: Lint** + + ```bash + cd apps/web && pnpm lint + ``` + +- [ ] **Step 4: Verify manually** + + 1. Open a collection with 5+ documents + 2. Select 2 rows — header checkbox should show dash/indeterminate state + 3. Select all rows — header checkbox should show checked state + 4. Click header checkbox when all selected — should deselect all + 5. Click header checkbox when none selected — should select all + +- [ ] **Step 5: Commit** + + ```bash + git add apps/web/src/components/nosql-explorer/document-view.tsx + git commit -m "fix(nosql): use Radix native indeterminate prop on select-all checkbox" + ``` + +--- + +## Sprint 2 — Performance + +--- + +### Task 4: Install @tanstack/react-virtual and memoize derived values + +**Files:** +- Modify: `apps/web/package.json` +- Modify: `components/nosql-explorer/document-view.tsx` + +**Interfaces:** +- Produces: `useVirtualizer` available for Task 5 + +- [ ] **Step 1: Install dependency** + + ```bash + cd apps/web && pnpm add @tanstack/react-virtual + ``` + Expected: package appears in `apps/web/package.json` dependencies. + +- [ ] **Step 2: Add useMemo imports to document-view.tsx** + + In `components/nosql-explorer/document-view.tsx`, ensure `useMemo` is in the React import: + ```tsx + import { useState, useEffect, useCallback, useMemo } from "react"; + ``` + +- [ ] **Step 3: Replace inline derived values with useMemo** + + Find the section around line 576 in `document-view.tsx` where these are declared: + ```tsx + const fields = Array.from(new Set(documents.flatMap(Object.keys))).filter(k => k !== "_id"); + const allFields = ["_id", ...fields]; + const totalPages = Math.ceil(total / limit) || 1; + const isAllSelected = documents.length > 0 && selectedIds.size === documents.length; + const isIndeterminate = selectedIds.size > 0 && selectedIds.size < documents.length; + const isFilterActive = (() => { ... })(); + ``` + + Replace with: + ```tsx + const fields = useMemo( + () => Array.from(new Set(documents.flatMap(Object.keys))).filter(k => k !== "_id"), + [documents] + ); + const allFields = useMemo(() => ["_id", ...fields], [fields]); + const totalPages = useMemo(() => Math.ceil(total / limit) || 1, [total, limit]); + const isAllSelected = useMemo( + () => documents.length > 0 && selectedIds.size === documents.length, + [selectedIds, documents] + ); + const isIndeterminate = useMemo( + () => selectedIds.size > 0 && selectedIds.size < documents.length, + [selectedIds, documents] + ); + const isFilterActive = useMemo(() => { + try { + const q = searchQuery?.trim(); + if (!q || q === "{}") return false; + return Object.keys(JSON.parse(q)).length > 0; + } catch { return false; } + }, [searchQuery]); + ``` + +- [ ] **Step 4: Defer JSON.stringify off the main thread** + + Find the useEffect around line 422: + ```tsx + useEffect(() => { + setJsonViewContent(JSON.stringify(documents, null, 2)); + }, [documents]); + ``` + Replace with: + ```tsx + useEffect(() => { + if (documents.length === 0) { + setJsonViewContent("[]"); + return; + } + const id = setTimeout(() => { + setJsonViewContent(JSON.stringify(documents, null, 2)); + }, 0); + return () => clearTimeout(id); + }, [documents]); + ``` + +- [ ] **Step 5: Lint** + + ```bash + cd apps/web && pnpm lint + ``` + Expected: 0 errors. + +- [ ] **Step 6: Verify manually** + + Open a collection with 500+ documents. Switch rapidly between Table/JSON/Tree views. The UI should not freeze. JSON view should render after a brief tick rather than blocking the switch animation. + +- [ ] **Step 7: Commit** + + ```bash + git add apps/web/package.json apps/web/src/components/nosql-explorer/document-view.tsx + git commit -m "perf(nosql): install react-virtual, memoize derived values, defer JSON stringify" + ``` + +--- + +### Task 5: Add connection cache to page.tsx + +**Files:** +- Modify: `app/app/nosql-explorer/page.tsx` +- Modify: `components/nosql-explorer/explorer-sidebar.tsx` + +**Interfaces:** +- Produces: `connectionCacheRef` available to all action handlers in `page.tsx` +- Consumes: `SavedConnection[]` type from `components/nosql-explorer/types.ts` + +- [ ] **Step 1: Add cache ref and onConnectionsLoaded prop to ExplorerSidebar** + + In `components/nosql-explorer/explorer-sidebar.tsx`, add `onConnectionsLoaded` to the props interface: + ```tsx + interface ExplorerSidebarProps { + onSelectCollection: (connection: SavedConnection, dbName: string, collectionName: string) => void; + onRefresh: () => void; + onAddConnection: () => void; + onConnectionsLoaded?: (connections: SavedConnection[]) => void; + width?: number; + } + ``` + + Destructure it in the component: + ```tsx + export function ExplorerSidebar({ + onSelectCollection, + onRefresh, + onAddConnection, + onConnectionsLoaded, + width = 256, + }: ExplorerSidebarProps) { + ``` + + In `loadConnections`, after `setConnections(newConnections)` (before the forEach that triggers expand), add: + ```tsx + onConnectionsLoaded?.(saved); + ``` + +- [ ] **Step 2: Add connectionCacheRef to page.tsx** + + In `app/app/nosql-explorer/page.tsx`, add `useRef` to React imports if not present: + ```tsx + import { useState, useEffect, useRef, useCallback } from "react"; + ``` + (It's already there — verify.) + + After the `autoFetchedTabsRef` declaration, add: + ```tsx + const connectionCacheRef = useRef>(new Map()); + ``` + +- [ ] **Step 3: Wire onConnectionsLoaded in both ExplorerSidebar usages** + + In `page.tsx`, both the desktop and mobile sidebar renders need the prop. Find both ` { + connectionCacheRef.current.clear(); + conns.forEach(c => connectionCacheRef.current.set(c.id, c)); + }} + ``` + +- [ ] **Step 4: Create getConnectionForTab helper** + + In `page.tsx`, add this helper after the `connectionCacheRef` declaration: + ```tsx + const getConnectionForTab = useCallback(async (tab: import("@/components/nosql-explorer/types").ExplorerTab) => { + const cached = connectionCacheRef.current.get(tab.connectionId); + if (cached) return cached; + if (!user || !encryptionKey) throw new Error("Not authenticated"); + const connections = await getConnections(user.uid, encryptionKey); + connections.forEach(c => connectionCacheRef.current.set(c.id, c)); + const conn = connections.find(c => c.id === tab.connectionId); + if (!conn) throw new Error("Connection not found — try refreshing the sidebar"); + return conn; + }, [user, encryptionKey]); + ``` + +- [ ] **Step 5: Replace all getConnections calls in action handlers** + + In `page.tsx`, every handler that calls: + ```tsx + const connections = await getConnections(user.uid, encryptionKey); + const conn = connections.find(c => c.id === activeTab.connectionId); + if (!conn) throw new Error("Connection not found"); + ``` + Replace with: + ```tsx + const conn = await getConnectionForTab(activeTab); + ``` + + Affected handlers: `handleRefresh`, `handleInsert`, `handleUpdate`, `confirmDelete`, `handleBulkDelete`, `handleImport`, `handleLoadSchema`, `handleLoadIndexes`, `handleDropIndex`, `handleCreateIndex`, `performFetch`. + + For `performFetch`, replace: + ```tsx + const performFetch = async (tab: ExplorerTab) => { + if (user && encryptionKey) { + const connections = await getConnections(user.uid, encryptionKey); + const conn = connections.find(c => c.id === tab.connectionId); + if (conn) { + fetchDocumentsForTab(tab, conn.connectionString); + } + } + }; + ``` + With: + ```tsx + const performFetch = async (tab: ExplorerTab) => { + try { + const conn = await getConnectionForTab(tab); + fetchDocumentsForTab(tab, conn.connectionString); + } catch (e: any) { + updateTab(tab.id, { loading: false, error: e.message }); + } + }; + ``` + +- [ ] **Step 6: Invalidate cache on connection add/delete/update** + + In the `ConnectionForm` `onConnect` callback in `page.tsx` (~line 836): + ```tsx + onConnect={async () => { + setIsConnectionDialogOpen(false); + setHasConnections(true); + connectionCacheRef.current.clear(); + toast.success(t("toastConnectionAdded")); + }} + ``` + The sidebar will reload and call `onConnectionsLoaded` which repopulates the cache. + +- [ ] **Step 7: Lint and type-check** + + ```bash + cd apps/web && pnpm lint + ``` + +- [ ] **Step 8: Verify manually** + + Open browser DevTools → Network tab → filter by `Fetch/XHR`. Open a collection, then edit a document. Before this fix you'd see a connection-fetch request before the PUT. After the fix, no extra requests — only the document update request fires. + +- [ ] **Step 9: Commit** + + ```bash + git add apps/web/src/app/app/nosql-explorer/page.tsx \ + apps/web/src/components/nosql-explorer/explorer-sidebar.tsx + git commit -m "perf(nosql): cache connection lookup, eliminate redundant storage reads per action" + ``` + +--- + +### Task 6: Fix handleTabClose stale closure + virtualize table rows + +**Files:** +- Modify: `app/app/nosql-explorer/page.tsx` +- Modify: `components/nosql-explorer/document-view.tsx` + +**Interfaces:** +- Consumes: `@tanstack/react-virtual` installed in Task 4 + +- [ ] **Step 1: Fix handleTabClose stale closure** + + In `app/app/nosql-explorer/page.tsx`, find `handleTabClose` (~line 249): + ```tsx + const handleTabClose = (tabId: string) => { + setTabs((prev) => prev.filter((t) => t.id !== tabId)); + if (activeTabId === tabId) { + const index = tabs.findIndex((t) => t.id === tabId); + const newActiveTab = tabs[index - 1] || tabs[index + 1]; + setActiveTabId(newActiveTab ? newActiveTab.id : null); + } + }; + ``` + Replace with: + ```tsx + const handleTabClose = (tabId: string) => { + setTabs((prev) => { + const next = prev.filter((t) => t.id !== tabId); + if (activeTabId === tabId) { + const idx = prev.findIndex((t) => t.id === tabId); + const nextTab = prev[idx - 1] ?? prev[idx + 1] ?? null; + setActiveTabId(nextTab?.id ?? null); + } + return next; + }); + }; + ``` + +- [ ] **Step 2: Add useVirtualizer import to document-view.tsx** + + In `components/nosql-explorer/document-view.tsx`, add: + ```tsx + import { useVirtualizer } from "@tanstack/react-virtual"; + import { useRef } from "react"; + ``` + (Add `useRef` to the existing React import if not already present.) + +- [ ] **Step 3: Add scroll container ref and virtualizer** + + Inside `DocumentView`, add a ref for the table scroll container and set up the virtualizer. Add after existing state declarations: + ```tsx + const tableContainerRef = useRef(null); + + const rowVirtualizer = useVirtualizer({ + count: documents.length, + getScrollElement: () => tableContainerRef.current, + estimateSize: () => 48, + overscan: 10, + }); + ``` + +- [ ] **Step 4: Apply virtualizer to table view render path** + + Find the table view render path (~line 926). The outer wrapper is: + ```tsx +
+
{t("actions")}
+ ``` + Add the ref to the outer div: + ```tsx +
+
+ ``` + + Replace the `` content. Find: + ```tsx + + {documents.map((doc, index) => { + ``` + Replace with: + ```tsx + + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const doc = documents[virtualRow.index]; + const index = virtualRow.index; + ``` + And wrap each `` to position it: + ```tsx + + ``` + Close with `})}` replacing the old `})}`. + +- [ ] **Step 5: Add aria-busy to loading skeleton table** + + Find the loading skeleton table (~line 801): + ```tsx +
+ ``` + Replace with: + ```tsx +
+ ``` + +- [ ] **Step 6: Lint** + + ```bash + cd apps/web && pnpm lint + ``` + +- [ ] **Step 7: Verify manually** + + 1. Set limit to 1000 or 2000 in the pagination selector + 2. Load a large collection + 3. Scroll the table — should be smooth with no jank + 4. Close a tab that is NOT the last one — the adjacent tab should become active (not null) + 5. Close the only remaining tab — content area should show empty state + +- [ ] **Step 8: Commit** + + ```bash + git add apps/web/src/app/app/nosql-explorer/page.tsx \ + apps/web/src/components/nosql-explorer/document-view.tsx + git commit -m "perf(nosql): fix tab close stale closure, virtualize table rows" + ``` + +--- + +## Sprint 3 — UX Improvements + +--- + +### Task 7: Add run button to query input + fix sort badge + fix tab labels + +**Files:** +- Modify: `components/nosql-explorer/query-builder.tsx` +- Modify: `components/nosql-explorer/document-view.tsx` +- Modify: `components/nosql-explorer/tab-bar.tsx` + +**Interfaces:** +- Produces: nothing consumed by other tasks + +- [ ] **Step 1: Add run button to QueryBuilder inline input** + + In `components/nosql-explorer/query-builder.tsx`, the `IconPlayerPlay` is already imported. In the absolute-positioned button group at the right edge of the input (~line 334), add a run button as the first item (before the existing clear button): + + Find: + ```tsx +
+ {/* Clear button — visible when query is non-empty/non-default */} + {textQuery && textQuery !== "{}" && ( + ``` + Insert before the clear button block: + ```tsx + +
+ ``` + +- [ ] **Step 2: Add active sort badge to breadcrumb in DocumentView** + + In `components/nosql-explorer/document-view.tsx`, find the breadcrumb bar (~line 603). It ends with: + ```tsx + {!loading && total > 0 && ( + <> + · + + {t("docsBreadcrumb", { ... })} + + + )} +
+ ``` + Add after the `total > 0` block (still inside the breadcrumb `
`): + ```tsx + {sortField && ( + + )} + ``` + +- [ ] **Step 3: Show db context in tab labels** + + In `components/nosql-explorer/tab-bar.tsx`, find the tab label (~line 61): + ```tsx + + {tab.collectionName} + + ``` + Replace with: + ```tsx + + + {tab.dbName.length > 8 ? `${tab.dbName.slice(0, 8)}…` : tab.dbName}· + + {tab.collectionName} + + ``` + +- [ ] **Step 4: Lint** + + ```bash + cd apps/web && pnpm lint + ``` + +- [ ] **Step 5: Verify manually** + + 1. Query input: type a query and click the `▶` button — results should filter without pressing Enter + 2. Sort a column by clicking its header — a "sorted by fieldName ↑" badge should appear in the breadcrumb bar + 3. Click the badge's `×` — sort should clear + 4. Switch to JSON view — badge should still be visible + 5. Open two tabs from different databases — tab labels should show `dbname·collectionName` + +- [ ] **Step 6: Commit** + + ```bash + git add apps/web/src/components/nosql-explorer/query-builder.tsx \ + apps/web/src/components/nosql-explorer/document-view.tsx \ + apps/web/src/components/nosql-explorer/tab-bar.tsx + git commit -m "ux(nosql): add run button to query, sort badge in breadcrumb, db context in tabs" + ``` + +--- + +### Task 8: Toolbar two-row split + Import/Export buttons + empty state fix + auto-expand + +**Files:** +- Modify: `components/nosql-explorer/document-view.tsx` +- Modify: `app/app/nosql-explorer/page.tsx` +- Modify: `components/nosql-explorer/explorer-sidebar.tsx` + +**Interfaces:** +- Produces: `onConnectionAdded?: (id: string) => void` on `ExplorerSidebar` + +- [ ] **Step 1: Split toolbar into two rows** + + In `components/nosql-explorer/document-view.tsx`, find the toolbar div (~line 627): + ```tsx +
+
+ +
+
+ {/* all action controls */} +
+
+ ``` + Replace with: + ```tsx +
+ {/* Row 1: Query */} +
+ { + setSearchQuery(q); + onSearch(q); + }} + fields={fields} + connectionName={connectionName} + dbName={dbName} + collectionName={collectionName} + /> +
+ {/* Row 2: Controls */} +
+ {/* Left: view mode + tree controls */} +
+ + + + + + {viewModeOptions.map(({ mode, icon: Icon, label }) => ( + setViewMode(mode as any)} + className={cn("gap-2 text-xs", viewMode === mode && "bg-accent font-medium")} + > + + {label} + + ))} + + + {viewMode === "tree" && ( + + + + + + {t("expandAll")} + + + + + + {t("collapseAll")} + + + )} +
+ + {/* Center: pagination */} +
+ +
+ + + {page}/{totalPages} + + +
+ + {/* Right: actions */} +
+ + + + + + {t("refreshData")} + + + + {onImport && ( + + )} + +
+
+
+ ``` + +- [ ] **Step 2: Remove Import/Export chooser state and dialog** + + Remove these from `DocumentView` state: + ```tsx + const [isImportExportChooserOpen, setIsImportExportChooserOpen] = useState(false); + ``` + Remove the `openImportFromChooser` and `openExportFromChooser` helper functions. + Remove the chooser `` block (~lines 1105–1146). + +- [ ] **Step 3: Fix empty state — mobile-aware direction hint** + + In `app/app/nosql-explorer/page.tsx`, add `useMediaQuery` import if not already: + ```tsx + import { useMediaQuery } from "@/hooks/use-media-query"; + ``` + It's already declared as `const isDesktop = useMediaQuery(...)` in the component. + + Find the "select collection" empty state (~line 813): + ```tsx +
+
+ +
+
+

{t("selectCollectionTitle")}

+

+ {t("selectCollectionDesc")} +

+
+
+ ``` + Replace with: + ```tsx +
+
+ +
+
+

{t("selectCollectionTitle")}

+

+ {isDesktop ? t("selectCollectionDesc") : t("selectCollectionDescMobile")} +

+
+ {!isDesktop && ( + + )} +
+ ``` + Add `IconLayoutSidebar` to the tabler import. Add i18n keys `selectCollectionDescMobile` and `openSidebar`. + +- [ ] **Step 4: Auto-expand newly added connection in sidebar** + + In `components/nosql-explorer/explorer-sidebar.tsx`, add prop: + ```tsx + interface ExplorerSidebarProps { + // ... existing props + autoExpandConnectionId?: string; + } + ``` + Destructure it: + ```tsx + export function ExplorerSidebar({ ..., autoExpandConnectionId }: ExplorerSidebarProps) { + ``` + In `loadConnections`, after `setConnections(newConnections)` and the forEach for expanded connections, add: + ```tsx + if (autoExpandConnectionId) { + const autoIdx = newConnections.findIndex(n => n.connection.id === autoExpandConnectionId); + if (autoIdx !== -1 && !newConnections[autoIdx].isExpanded) { + refreshDatabases(autoIdx, newConnections[autoIdx].connection); + setConnections(prev => prev.map((c, i) => i === autoIdx ? { ...c, isExpanded: true } : c)); + } + } + ``` + + In `page.tsx`, add state: + ```tsx + const [autoExpandConnectionId, setAutoExpandConnectionId] = useState(undefined); + ``` + Update `ConnectionForm.onConnect` callback to pass the new id. First, update `connection-service.ts`'s `saveConnection` return type to include the new `id` (check the existing return value — if it already returns it, pass it through the callback chain). If `saveConnection` returns the saved connection, update the callback in `page.tsx`: + ```tsx + onConnect={async (newId?: string) => { + setIsConnectionDialogOpen(false); + setHasConnections(true); + connectionCacheRef.current.clear(); + if (newId) setAutoExpandConnectionId(newId); + toast.success(t("toastConnectionAdded")); + }} + ``` + Pass `autoExpandConnectionId` to both `` usages. + +- [ ] **Step 5: Lint** + + ```bash + cd apps/web && pnpm lint + ``` + +- [ ] **Step 6: Verify manually** + + 1. Toolbar should show query on row 1, controls on row 2 — no labels hidden with `hidden sm:inline` + 2. Separate Import and Export buttons visible in toolbar + 3. No "Import/Export" chooser dialog appears + 4. On mobile: empty state shows "Open the sidebar" button that opens the sheet + 5. Add a new connection — sidebar should auto-expand it showing its databases + +- [ ] **Step 7: Commit** + + ```bash + git add apps/web/src/components/nosql-explorer/document-view.tsx \ + apps/web/src/app/app/nosql-explorer/page.tsx \ + apps/web/src/components/nosql-explorer/explorer-sidebar.tsx + git commit -m "ux(nosql): two-row toolbar, split import/export, mobile empty state, auto-expand connection" + ``` + +--- + +## Sprint 4a — Accessibility + +--- + +### Task 9: Table accessibility — aria-sort, column resize keyboard, live region, table labels + +**Files:** +- Modify: `components/nosql-explorer/document-view.tsx` + +**Interfaces:** +- Produces: nothing consumed by other tasks + +- [ ] **Step 1: Add aria-sort to sortable column headers** + + In `components/nosql-explorer/document-view.tsx`, find the `
+ ``` + Replace with: + ```tsx + + ``` + +- [ ] **Step 2: Add aria-label to main table** + + Find the table view container (~line 928): + ```tsx +
` elements in the table header map (~line 943): + ```tsx + +
handleSort(key)}> + {key} + {sortField === key ? ... : '↕'} +
+
handleColumnResize(key, e)} /> +
+ +
handleColumnResize(key, e)} + onKeyDown={(e) => { + if (e.key === "ArrowRight") setColumnWidths(prev => ({ ...prev, [key]: Math.max(50, (prev[key] ?? 300) + 10) })); + if (e.key === "ArrowLeft") setColumnWidths(prev => ({ ...prev, [key]: Math.max(50, (prev[key] ?? 300) - 10) })); + }} + /> +
+ ``` + Already updated in Task 6 — confirm it has `aria-label={`${collectionName} documents`}`. If not, add it. + +- [ ] **Step 3: Add live region to bulk action bar** + + Find the bulk action bar (~line 750): + ```tsx + + {selectedIds.size} document{selectedIds.size > 1 ? 's' : ''} selected + + ``` + Replace with: + ```tsx + + {selectedIds.size} document{selectedIds.size > 1 ? "s" : ""} selected + + ``` + +- [ ] **Step 4: Fix advanced editor dialog close button** + + In `components/nosql-explorer/query-builder.tsx`, find (~line 546): + ```tsx + + ``` + Replace with: + ```tsx + + ``` + This keeps the default Radix close button accessible to screen readers (`sr-only`) while keeping it visually hidden. The custom `×` button in the header remains the visible control. + +- [ ] **Step 5: Lint** + + ```bash + cd apps/web && pnpm lint + ``` + +- [ ] **Step 6: Verify manually** + + 1. Tab into table headers — each `
` sort button should be focusable with keyboard + 2. Focus a column resize handle, press `ArrowRight`/`ArrowLeft` — column should resize + 3. Select 3 rows — browser accessibility inspector should show live region announcing "3 documents selected" + 4. Sort by a column — `aria-sort` attribute should be set on the sorted `` (inspect in DevTools Elements panel) + 5. Open advanced query editor — press Escape — dialog should close (Radix native behavior preserved) + +- [ ] **Step 7: Commit** + + ```bash + git add apps/web/src/components/nosql-explorer/document-view.tsx \ + apps/web/src/components/nosql-explorer/query-builder.tsx + git commit -m "a11y(nosql): aria-sort on columns, keyboard resize, live region, dialog close fix" + ``` + +--- + +### Task 10: Sidebar tree keyboard navigation + +**Files:** +- Modify: `components/nosql-explorer/explorer-sidebar.tsx` + +**Interfaces:** +- Produces: nothing consumed by other tasks + +- [ ] **Step 1: Add focusedNodeKey state and tree container ref** + + In `components/nosql-explorer/explorer-sidebar.tsx`, add inside `ExplorerSidebar`: + ```tsx + const treeRef = useRef(null); + ``` + Add `useRef` to the React import if not already present. + +- [ ] **Step 2: Add role="tree" to the tree container** + + Find the connections list container (~line 436): + ```tsx +
+
+ ``` + Add `role` to the inner div: + ```tsx +
+
+ ``` + +- [ ] **Step 3: Add role="treeitem" and aria-expanded to connection buttons** + + Find the connection button (~line 470): + ```tsx + + + +
+
+
+ {(isExpanded ? allFields.filter(k => k !== "_id") : visibleFields).map(key => ( +
+ {key} +
+ +
+
+ ))} + {hiddenCount > 0 && ( + + )} +
+
+ ); + })} +
+ + ``` + +- [ ] **Step 4: Add tab bar overflow fade gradient** + + In `components/nosql-explorer/tab-bar.tsx`, find the return's outer div: + ```tsx +
+ + ``` + Wrap ScrollArea in a relative container with fade: + ```tsx +
+
+ +
+ {safeTabs.map(...)} +
+ +
+
+
+ {tabs.length > 0 && onCloseAll && ( +
+ ... +
+ )} +
+ ``` + +- [ ] **Step 5: Lint** + + ```bash + cd apps/web && pnpm lint + ``` + +- [ ] **Step 6: Verify manually (requires mobile viewport)** + + 1. Open Chrome DevTools → toggle device toolbar → select iPhone 14 viewport + 2. Navigate to nosql-explorer, open a collection + 3. Should show card view, not horizontal-scroll table + 4. Each card shows `_id` + top 5 fields + action buttons + 5. "+N more fields" link expands card + 6. Tab bar with 4+ tabs: right edge should have a fade gradient indicating more tabs exist + +- [ ] **Step 7: Commit** + + ```bash + git add apps/web/src/components/nosql-explorer/document-view.tsx \ + apps/web/src/components/nosql-explorer/tab-bar.tsx + git commit -m "mobile(nosql): card view for documents on small screens, tab bar fade gradient" + ``` + +--- + +### Task 12: Mobile query builder sheet + connection form scroll + Monaco → Textarea on mobile + +**Files:** +- Modify: `components/nosql-explorer/query-builder.tsx` +- Modify: `components/nosql-explorer/connection-form.tsx` +- Modify: `components/nosql-explorer/document-view.tsx` + +**Interfaces:** +- Consumes: `useMediaQuery` from `@/hooks/use-media-query` + +- [ ] **Step 1: Fix query builder popover width overflow** + + In `components/nosql-explorer/query-builder.tsx`, find: + ```tsx + + ``` + Replace with: + ```tsx + + ``` + +- [ ] **Step 2: Add useMediaQuery to QueryBuilder** + + In `components/nosql-explorer/query-builder.tsx`: + ```tsx + import { useMediaQuery } from "@/hooks/use-media-query"; + ``` + Inside the component: + ```tsx + const isDesktop = useMediaQuery("(min-width: 768px)"); + ``` + +- [ ] **Step 3: Replace filter Popover with Sheet on mobile** + + Add Sheet import: + ```tsx + import { Sheet, SheetContent, SheetTitle, SheetHeader } from "@/components/ui/sheet"; + ``` + Add state for mobile sheet: + ```tsx + const [mobileBuilderOpen, setMobileBuilderOpen] = useState(false); + ``` + + The filter builder button currently opens `setBuilderOpen(true)`. Update to: + ```tsx + onClick={isDesktop ? openBuilder : () => { openBuilder(); setMobileBuilderOpen(true); }} + ``` + + After the existing Popover, add a mobile Sheet: + ```tsx + {!isDesktop && ( + + + + {t("filterRules")} + + +
+ {rules.length === 0 && ( +
{t("noFilters")}
+ )} + {rules.map((rule, index) => ( +
+
{index === 0 ? t("where") : t("and")}
+
+ + +
+ updateRule(rule.id, { value: e.target.value })} className="h-9 text-xs" placeholder={t("valuePlaceholder")} /> + +
+ ))} +
+
+
+ + +
+
+
+ )} + ``` + +- [ ] **Step 4: Fix connection form scroll on mobile** + + In `components/nosql-explorer/connection-form.tsx`, find (~line 167): + ```tsx +
+ ``` + Replace with: + ```tsx +
+ ``` + +- [ ] **Step 5: Monaco → Textarea on mobile in edit/insert dialogs** + + In `components/nosql-explorer/document-view.tsx`, ensure `useMediaQuery` import and `isDesktop` are present (added in Task 11). + + Find the edit dialog Monaco editor (~line 1047): + ```tsx +
+ setEditorContent(v || "")} + theme={theme === 'dark' ? 'vs-dark' : 'light'} + options={{ minimap: { enabled: false }, fontSize: 14 }} + /> +
+ ``` + Replace with: + ```tsx +
+ {isDesktop ? ( + setEditorContent(v || "")} + theme={theme === 'dark' ? 'vs-dark' : 'light'} + options={{ minimap: { enabled: false }, fontSize: 14 }} + /> + ) : ( +