diff --git a/electron/controllers/authController.js b/electron/controllers/authController.js index b9e5ab0..0db5be2 100644 --- a/electron/controllers/authController.js +++ b/electron/controllers/authController.js @@ -6,6 +6,7 @@ const { ipcMain } = require('electron'); const fetch = require('node-fetch'); +const { getAuth } = require('firebase-admin/auth'); let adminRef = null; let authEmulatorHost = null; @@ -19,8 +20,8 @@ function setAuthEmulatorHost(host) { } function getProjectId() { - if (adminRef?.apps?.length > 0) { - return adminRef.app().options.projectId; + if (adminRef?.getApps().length > 0) { + return adminRef.getApps()[0].options.projectId; } return null; } @@ -77,8 +78,8 @@ function registerHandlers() { return { success: true, users }; } - if (!adminRef?.apps?.length) throw new Error('Not connected to Firebase'); - const listUsersResult = await adminRef.auth().listUsers(maxResults); + if (!adminRef?.getApps().length) throw new Error('Not connected to Firebase'); + const listUsersResult = await getAuth().listUsers(maxResults); const users = listUsersResult.users.map((user) => ({ uid: user.uid, email: user.email, @@ -134,7 +135,7 @@ function registerHandlers() { }; } - if (!adminRef?.apps?.length) throw new Error('Not connected to Firebase'); + if (!adminRef?.getApps().length) throw new Error('Not connected to Firebase'); const userData = { email, password }; if (displayName) userData.displayName = displayName; if (phoneNumber) userData.phoneNumber = phoneNumber; @@ -142,7 +143,7 @@ function registerHandlers() { if (photoURL) userData.photoURL = photoURL; if (disabled) userData.disabled = disabled; if (emailVerified) userData.emailVerified = emailVerified; - const userRecord = await adminRef.auth().createUser(userData); + const userRecord = await getAuth().createUser(userData); return { success: true, user: { uid: userRecord.uid, email: userRecord.email, displayName: userRecord.displayName }, @@ -185,7 +186,7 @@ function registerHandlers() { }; } - if (!adminRef?.apps?.length) throw new Error('Not connected to Firebase'); + if (!adminRef?.getApps().length) throw new Error('Not connected to Firebase'); const updateData = {}; if (email !== undefined) updateData.email = email; if (password !== undefined) updateData.password = password; @@ -194,7 +195,7 @@ function registerHandlers() { if (disabled !== undefined) updateData.disabled = disabled; if (photoURL !== undefined) updateData.photoURL = photoURL; if (emailVerified !== undefined) updateData.emailVerified = emailVerified; - const userRecord = await adminRef.auth().updateUser(uid, updateData); + const userRecord = await getAuth().updateUser(uid, updateData); return { success: true, user: { @@ -230,8 +231,8 @@ function registerHandlers() { return { success: true }; } - if (!adminRef?.apps?.length) throw new Error('Not connected to Firebase'); - await adminRef.auth().deleteUser(uid); + if (!adminRef?.getApps().length) throw new Error('Not connected to Firebase'); + await getAuth().deleteUser(uid); return { success: true }; } catch (error) { return { success: false, error: error.message }; @@ -261,8 +262,8 @@ function registerHandlers() { return { success: true, user: normalizeUserRecord(data.users[0]) }; } - if (!adminRef?.apps?.length) throw new Error('Not connected to Firebase'); - const userRecord = await adminRef.auth().getUser(uid); + if (!adminRef?.getApps().length) throw new Error('Not connected to Firebase'); + const userRecord = await getAuth().getUser(uid); return { success: true, user: { diff --git a/electron/controllers/firebaseController.js b/electron/controllers/firebaseController.js index e16d732..6df125e 100644 --- a/electron/controllers/firebaseController.js +++ b/electron/controllers/firebaseController.js @@ -5,6 +5,7 @@ const { ipcMain, dialog } = require('electron'); const fs = require('fs'); +const { getFirestore } = require('firebase-admin/firestore'); let admin = null; let db = null; @@ -65,10 +66,11 @@ function registerHandlers() { // Never call app().delete() unless an app exists — after a failed connect, `admin` may // still reference the SDK module while no default app was initialized, which throws: // "The default Firebase app does not exist". - const existingApps = [...adminSdk.apps]; + // ponytail: firebase-admin v14 removed the `apps` getter and `app.delete()`; use getApps() + deleteApp() + const existingApps = adminSdk.getApps(); for (const appInstance of existingApps) { try { - await appInstance.delete(); + await adminSdk.deleteApp(appInstance); } catch (e) { void e; } @@ -80,7 +82,7 @@ function registerHandlers() { const serviceAccount = JSON.parse(fs.readFileSync(serviceAccountPath, 'utf8')); projectId = serviceAccount.project_id; adminSdk.initializeApp({ - credential: adminSdk.credential.cert(serviceAccount), + credential: adminSdk.cert(serviceAccount), projectId, }); } else if (emulatorHost && explicitProjectId) { @@ -91,7 +93,7 @@ function registerHandlers() { } admin = adminSdk; - db = adminSdk.firestore(); + db = getFirestore(); if (databaseId) { db.settings({ databaseId }); @@ -110,10 +112,10 @@ function registerHandlers() { currentStorageEmulatorHost = null; try { const adminSdk = require('firebase-admin'); - const leftover = [...adminSdk.apps]; + const leftover = adminSdk.getApps(); for (const appInstance of leftover) { try { - await appInstance.delete(); + await adminSdk.deleteApp(appInstance); } catch (e) { void e; } @@ -132,10 +134,10 @@ function registerHandlers() { ipcMain.handle('firebase:disconnect', async () => { try { const adminSdk = admin || require('firebase-admin'); - const existingApps = [...adminSdk.apps]; + const existingApps = adminSdk.getApps(); for (const appInstance of existingApps) { try { - await appInstance.delete(); + await adminSdk.deleteApp(appInstance); } catch (e) { void e; } diff --git a/electron/controllers/firebaseController.test.js b/electron/controllers/firebaseController.test.js index 7c7707f..93d4373 100644 --- a/electron/controllers/firebaseController.test.js +++ b/electron/controllers/firebaseController.test.js @@ -8,7 +8,7 @@ const readFileSyncMock = vi.fn(); const handleMock = vi.fn(); const mockAppDelete = vi.fn().mockResolvedValue(undefined); const mockSettings = vi.fn(); -/** Simulates firebase-admin `apps` after initializeApp */ +/** Simulates firebase-admin apps after initializeApp (v14: getApps() returns an array) */ const mockAppsList = []; // Inject electron mock into require cache @@ -32,7 +32,7 @@ require_.cache[require_.resolve('fs')] = { }, }; -// Inject firebase-admin mock +// Inject firebase-admin mock (v14 modular API) const firebaseAdminPath = require_.resolve('firebase-admin'); require_.cache[firebaseAdminPath] = { id: 'firebase-admin', @@ -40,16 +40,24 @@ require_.cache[firebaseAdminPath] = { loaded: true, exports: { initializeApp: vi.fn(() => { - const app = { delete: mockAppDelete }; + const app = { options: {}, delete: mockAppDelete }; mockAppsList.push(app); return app; }), - credential: { cert: vi.fn().mockReturnValue('mock-credential') }, - firestore: vi.fn().mockReturnValue({ settings: mockSettings }), - app: vi.fn(() => mockAppsList[0]), - get apps() { - return mockAppsList; - }, + cert: vi.fn().mockReturnValue('mock-credential'), + getApps: () => mockAppsList, + deleteApp: mockAppDelete, + }, +}; + +// Inject firebase-admin/firestore mock (v14 modular API) +const firestoreModulePath = require_.resolve('firebase-admin/firestore'); +require_.cache[firestoreModulePath] = { + id: 'firebase-admin/firestore', + filename: firestoreModulePath, + loaded: true, + exports: { + getFirestore: vi.fn().mockReturnValue({ settings: mockSettings }), }, }; diff --git a/electron/controllers/firestoreController.js b/electron/controllers/firestoreController.js index aaa2e51..5c5f69b 100644 --- a/electron/controllers/firestoreController.js +++ b/electron/controllers/firestoreController.js @@ -6,10 +6,10 @@ const { ipcMain, dialog } = require('electron'); const fs = require('fs'); const vm = require('vm'); +const { FieldValue, Filter, Timestamp, GeoPoint } = require('firebase-admin/firestore'); const { fetchDocumentsPage } = require('./firestore/documentList'); const { firestoreDocumentToData } = require('../utils/firestoreHelpers'); -let adminRef = null; let dbRef = null; function normalizeFirestoreError(error) { @@ -24,10 +24,11 @@ function normalizeFirestoreError(error) { } /** - * Sets references to admin SDK and database + * Sets reference to the Firestore database. The `admin` arg is accepted for + * backwards compatibility with callers but is unused under firebase-admin v14. */ function setRefs(admin, db) { - adminRef = admin; + void admin; dbRef = db; } @@ -235,10 +236,7 @@ function registerHandlers() { if (!dbRef) throw new Error('Not connected to Firebase'); const wrappedCode = `(async () => { ${jsQuery} return await run(); })()`; - const FieldValue = adminRef ? adminRef.firestore.FieldValue : null; - const Filter = adminRef ? adminRef.firestore.Filter : null; - const Timestamp = adminRef ? adminRef.firestore.Timestamp : null; - const GeoPoint = adminRef ? adminRef.firestore.GeoPoint : null; + // ponytail: firebase-admin v14 removed the `.firestore` namespace; these are top-level exports const context = vm.createContext({ db: dbRef, FieldValue, diff --git a/electron/controllers/firestoreController.test.js b/electron/controllers/firestoreController.test.js index 816e3b2..c079c65 100644 --- a/electron/controllers/firestoreController.test.js +++ b/electron/controllers/firestoreController.test.js @@ -28,6 +28,19 @@ require_.cache[require_.resolve('fs')] = { }, }; +// firestoreController (v14) imports FieldValue/Filter/Timestamp/GeoPoint from firebase-admin/firestore +require_.cache[require_.resolve('firebase-admin/firestore')] = { + id: 'firebase-admin/firestore', + filename: require_.resolve('firebase-admin/firestore'), + loaded: true, + exports: { + FieldValue: { serverTimestamp: vi.fn() }, + Filter: { where: vi.fn() }, + Timestamp: { now: vi.fn() }, + GeoPoint: vi.fn(), + }, +}; + // Load controller with mocked deps const controllerPath = require_.resolve('./firestoreController'); delete require_.cache[controllerPath]; @@ -253,16 +266,9 @@ describe('firestoreController', () => { }); const mockDb = { collection: mockCollection }; - const mockAdmin = { - firestore: { - FieldValue: { serverTimestamp: vi.fn() }, - Filter: { where: vi.fn() }, - Timestamp: { now: vi.fn() }, - GeoPoint: vi.fn(), - }, - }; - - setRefs(mockAdmin, mockDb); + // v14: FieldValue/Filter/Timestamp/GeoPoint come from firebase-admin/firestore (mocked above), + // not from adminRef — so adminRef is no longer needed here. + setRefs(null, mockDb); const jsQuery = ` function run() { diff --git a/electron/controllers/storage/bucketResolver.js b/electron/controllers/storage/bucketResolver.js index 087bea1..7fa9718 100644 --- a/electron/controllers/storage/bucketResolver.js +++ b/electron/controllers/storage/bucketResolver.js @@ -1,4 +1,5 @@ const fetch = require('node-fetch'); +const { getStorage } = require('firebase-admin/storage'); const cache = new Map(); @@ -20,7 +21,7 @@ async function resolveWithProbe(cacheKey, candidates, probe) { function resolveAdminBucket(adminRef, projectId) { return resolveWithProbe(`admin:${projectId}`, bucketCandidates(projectId), async (bucketName) => { try { - const [exists] = await adminRef.storage().bucket(bucketName).exists(); + const [exists] = await getStorage().bucket(bucketName).exists(); return exists; } catch { return false; diff --git a/electron/controllers/storage/bucketResolver.test.js b/electron/controllers/storage/bucketResolver.test.js index ecf7a2b..4a9f23a 100644 --- a/electron/controllers/storage/bucketResolver.test.js +++ b/electron/controllers/storage/bucketResolver.test.js @@ -12,6 +12,15 @@ require_.cache[require_.resolve('node-fetch')] = { exports: fetchMock, }; +// bucketResolver (v14) uses getStorage() from firebase-admin/storage +const getStorageMock = { bucket: vi.fn() }; +require_.cache[require_.resolve('firebase-admin/storage')] = { + id: 'firebase-admin/storage', + filename: require_.resolve('firebase-admin/storage'), + loaded: true, + exports: { getStorage: () => getStorageMock }, +}; + const resolverPath = require_.resolve('./bucketResolver'); delete require_.cache[resolverPath]; const { resolveAdminBucket, resolveOauthBucket, resolveEmulatorBucket, clearBucketCache } = require_(resolverPath); @@ -24,7 +33,8 @@ function adminRefWithBuckets(existingBuckets) { return Promise.resolve([existingBuckets.includes(name)]); }, })); - return { adminRef: { storage: () => ({ bucket: bucketMock }) }, existsMock }; + getStorageMock.bucket = bucketMock; + return { existsMock }; } describe('bucketResolver', () => { @@ -64,13 +74,11 @@ describe('bucketResolver', () => { }); it('resolveAdminBucket treats probe errors as missing buckets', async () => { - const adminRef = { - storage: () => ({ - bucket: () => ({ exists: () => Promise.reject(new Error('network')) }), - }), - }; + getStorageMock.bucket = vi.fn(() => ({ + exists: () => Promise.reject(new Error('network')), + })); - expect(await resolveAdminBucket(adminRef, 'p1')).toBe('p1.firebasestorage.app'); + expect(await resolveAdminBucket(null, 'p1')).toBe('p1.firebasestorage.app'); }); it('clearBucketCache forces a re-probe', async () => { diff --git a/electron/controllers/storageController.js b/electron/controllers/storageController.js index 2d6df25..eedb940 100644 --- a/electron/controllers/storageController.js +++ b/electron/controllers/storageController.js @@ -7,6 +7,7 @@ const { ipcMain, dialog } = require('electron'); const path = require('path'); const fs = require('fs'); const fetch = require('node-fetch'); +const { getStorage } = require('firebase-admin/storage'); const googleController = require('./googleController'); const { resolveAdminBucket, @@ -52,8 +53,9 @@ function normalizeStorageItem(item, prefix) { } function getProjectId() { - if (adminRef?.apps?.length > 0) { - return adminRef.app().options.credential?.projectId || adminRef.app().options.projectId; + if (adminRef?.getApps().length > 0) { + const app = adminRef.getApps()[0]; + return app.options.credential?.projectId || app.options.projectId; } return null; } @@ -90,7 +92,7 @@ function registerHandlers() { return { success: true, items: [...folders, ...fileList], currentPath: storagePath }; } - const bucket = adminRef.storage().bucket(bucketName); + const bucket = getStorage().bucket(bucketName); const [files] = await bucket.getFiles({ prefix, delimiter: '/', autoPaginate: false }); const [, , apiResponse] = await bucket.getFiles({ prefix, delimiter: '/', autoPaginate: false }); @@ -148,7 +150,7 @@ function registerHandlers() { return { success: true, fileName, path: destination }; } - const bucket = adminRef.storage().bucket(bucketName); + const bucket = getStorage().bucket(bucketName); await bucket.upload(localPath, { destination, metadata: { contentType: require('mime-types').lookup(localPath) || 'application/octet-stream' }, @@ -181,7 +183,7 @@ function registerHandlers() { return { success: true, savedTo: savePath }; } - const bucket = adminRef.storage().bucket(bucketName); + const bucket = getStorage().bucket(bucketName); await bucket.file(filePath).download({ destination: savePath }); return { success: true, savedTo: savePath }; } catch (error) { @@ -210,7 +212,7 @@ function registerHandlers() { return { success: true, url }; } - const bucket = adminRef.storage().bucket(bucketName); + const bucket = getStorage().bucket(bucketName); const expiration = expiresInMs || 7 * 24 * 60 * 60 * 1000; const expiresDate = new Date(Date.now() + expiration); const expiresString = `${String(expiresDate.getMonth() + 1).padStart(2, '0')}-${String(expiresDate.getDate()).padStart(2, '0')}-${expiresDate.getFullYear()}`; @@ -242,7 +244,7 @@ function registerHandlers() { return { success: true }; } - await adminRef.storage().bucket(bucketName).file(filePath).delete(); + await getStorage().bucket(bucketName).file(filePath).delete(); return { success: true }; } catch (error) { return { success: false, error: error.message }; @@ -272,8 +274,7 @@ function registerHandlers() { return { success: true, folderPath }; } - await adminRef - .storage() + await getStorage() .bucket(bucketName) .file(placeholderPath) .save('', { metadata: { contentType: 'application/x-empty' } }); @@ -312,7 +313,7 @@ function registerHandlers() { }; } - const [metadata] = await adminRef.storage().bucket(bucketName).file(filePath).getMetadata(); + const [metadata] = await getStorage().bucket(bucketName).file(filePath).getMetadata(); return { success: true, metadata: { diff --git a/electron/controllers/storageController.test.js b/electron/controllers/storageController.test.js index 6336ea8..15b62ae 100644 --- a/electron/controllers/storageController.test.js +++ b/electron/controllers/storageController.test.js @@ -43,6 +43,15 @@ require_.cache[require_.resolve('./storage/bucketResolver')] = { }, }; +// storageController (v14) uses getStorage() from firebase-admin/storage +const getStorageMock = { bucket: vi.fn() }; +require_.cache[require_.resolve('firebase-admin/storage')] = { + id: 'firebase-admin/storage', + filename: require_.resolve('firebase-admin/storage'), + loaded: true, + exports: { getStorage: () => getStorageMock }, +}; + const controllerPath = require_.resolve('./storageController'); delete require_.cache[controllerPath]; const { registerHandlers, setAdminRef, setStorageEmulatorHost } = require_(controllerPath); @@ -54,10 +63,9 @@ for (const [channel, handler] of handleMock.mock.calls) { } function adminRefForProject(projectId, bucketImpl) { + getStorageMock.bucket = bucketImpl; return { - apps: [{}], - app: () => ({ options: { projectId } }), - storage: () => ({ bucket: bucketImpl }), + getApps: () => [{ options: { projectId } }], }; } diff --git a/src/features/collections/components/FilterSortToolbar.tsx b/src/features/collections/components/FilterSortToolbar.tsx index 2beb0c1..c11e407 100644 --- a/src/features/collections/components/FilterSortToolbar.tsx +++ b/src/features/collections/components/FilterSortToolbar.tsx @@ -1,14 +1,5 @@ import React, { useEffect, useMemo, useRef } from 'react'; -import { - Autocomplete, - Box, - Button, - IconButton, - MenuItem, - Select, - TextField, - Typography, -} from '@mui/material'; +import { Autocomplete, Box, Button, IconButton, MenuItem, Select, TextField, Typography } from '@mui/material'; import { FilterList as FilterIcon, Sort as SortIcon } from '@mui/icons-material'; import { Filter, SortConfig } from '../store/collectionSlice'; diff --git a/src/features/collections/store/collectionSlice.ts b/src/features/collections/store/collectionSlice.ts index abee32f..8ce8274 100644 --- a/src/features/collections/store/collectionSlice.ts +++ b/src/features/collections/store/collectionSlice.ts @@ -182,10 +182,7 @@ function parseSimpleFilterValue(value: FirestoreValue): FirestoreValue { if (trimmed === 'null') return null; if (trimmed !== '' && !Number.isNaN(Number(trimmed))) return Number(trimmed); - if ( - (trimmed.startsWith('[') && trimmed.endsWith(']')) || - (trimmed.startsWith('{') && trimmed.endsWith('}')) - ) { + if ((trimmed.startsWith('[') && trimmed.endsWith(']')) || (trimmed.startsWith('{') && trimmed.endsWith('}'))) { try { return JSON.parse(trimmed) as FirestoreValue; } catch { diff --git a/src/features/projects/components/ProjectSidebar.tsx b/src/features/projects/components/ProjectSidebar.tsx index 5b3c854..41b7cc5 100644 --- a/src/features/projects/components/ProjectSidebar.tsx +++ b/src/features/projects/components/ProjectSidebar.tsx @@ -148,9 +148,7 @@ function ProjectSidebar({ const handleContextMenu = ( e: React.MouseEvent, target: - | MenuTarget - | Project - | { project: Project | GoogleAccount; collection: string; firestoreDatabaseId?: string }, + MenuTarget | Project | { project: Project | GoogleAccount; collection: string; firestoreDatabaseId?: string }, type: Exclude, ) => { e.preventDefault(); diff --git a/src/features/projects/components/sidebar/SidebarProjectsList.tsx b/src/features/projects/components/sidebar/SidebarProjectsList.tsx index 1321bd0..6f297e8 100644 --- a/src/features/projects/components/sidebar/SidebarProjectsList.tsx +++ b/src/features/projects/components/sidebar/SidebarProjectsList.tsx @@ -55,9 +55,7 @@ interface SidebarProjectsListProps { handleContextMenu: ( e: React.MouseEvent, target: - | Project - | { project: Project | GoogleAccount; collection: string; firestoreDatabaseId?: string } - | MenuTarget, + Project | { project: Project | GoogleAccount; collection: string; firestoreDatabaseId?: string } | MenuTarget, type: Exclude, ) => void; isMenuOpen: boolean; diff --git a/src/features/projects/store/projectsSlice.ts b/src/features/projects/store/projectsSlice.ts index 2e02f68..d98fdb7 100644 --- a/src/features/projects/store/projectsSlice.ts +++ b/src/features/projects/store/projectsSlice.ts @@ -527,8 +527,7 @@ export const refreshGoogleAccountProjects = createAppAsyncThunk( try { const state = getState(); const existingAccount = state.projects.items.find((item) => isGoogleAccount(item) && item.id === accountId) as - | GoogleAccount - | undefined; + GoogleAccount | undefined; const effectiveRefreshToken = refreshToken ?? existingAccount?.refreshToken; let accessToken = existingAccount?.accessToken;