mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-03 12:10:11 +00:00
[Api Node] Firebase auth and user auth store (#3467)
This commit is contained in:
1158
package-lock.json
generated
1158
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -91,6 +91,7 @@
|
|||||||
"algoliasearch": "^5.21.0",
|
"algoliasearch": "^5.21.0",
|
||||||
"axios": "^1.8.2",
|
"axios": "^1.8.2",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
|
"firebase": "^11.6.0",
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
"jsondiffpatch": "^0.6.0",
|
"jsondiffpatch": "^0.6.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
@@ -103,6 +104,7 @@
|
|||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-i18n": "^9.14.3",
|
"vue-i18n": "^9.14.3",
|
||||||
"vue-router": "^4.4.3",
|
"vue-router": "^4.4.3",
|
||||||
|
"vuefire": "^3.2.1",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
"zod-validation-error": "^3.3.0"
|
"zod-validation-error": "^3.3.0"
|
||||||
}
|
}
|
||||||
|
|||||||
12
src/config/firebase.ts
Normal file
12
src/config/firebase.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { FirebaseOptions } from 'firebase/app'
|
||||||
|
|
||||||
|
export const FIREBASE_CONFIG: FirebaseOptions = {
|
||||||
|
apiKey: 'AIzaSyC2-fomLqgCjb7ELwta1I9cEarPK8ziTGs',
|
||||||
|
authDomain: 'dreamboothy.firebaseapp.com',
|
||||||
|
databaseURL: 'https://dreamboothy-default-rtdb.firebaseio.com',
|
||||||
|
projectId: 'dreamboothy',
|
||||||
|
storageBucket: 'dreamboothy.appspot.com',
|
||||||
|
messagingSenderId: '357148958219',
|
||||||
|
appId: '1:357148958219:web:f5917f72e5f36a2015310e',
|
||||||
|
measurementId: 'G-3ZBD3MBTG4'
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import '@comfyorg/litegraph/style.css'
|
|||||||
import { definePreset } from '@primevue/themes'
|
import { definePreset } from '@primevue/themes'
|
||||||
import Aura from '@primevue/themes/aura'
|
import Aura from '@primevue/themes/aura'
|
||||||
import * as Sentry from '@sentry/vue'
|
import * as Sentry from '@sentry/vue'
|
||||||
|
import { initializeApp } from 'firebase/app'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import 'primeicons/primeicons.css'
|
import 'primeicons/primeicons.css'
|
||||||
import PrimeVue from 'primevue/config'
|
import PrimeVue from 'primevue/config'
|
||||||
@@ -9,8 +10,10 @@ import ConfirmationService from 'primevue/confirmationservice'
|
|||||||
import ToastService from 'primevue/toastservice'
|
import ToastService from 'primevue/toastservice'
|
||||||
import Tooltip from 'primevue/tooltip'
|
import Tooltip from 'primevue/tooltip'
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
|
import { VueFire, VueFireAuth } from 'vuefire'
|
||||||
|
|
||||||
import '@/assets/css/style.css'
|
import '@/assets/css/style.css'
|
||||||
|
import { FIREBASE_CONFIG } from '@/config/firebase'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
@@ -23,6 +26,8 @@ const ComfyUIPreset = definePreset(Aura, {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const firebaseApp = initializeApp(FIREBASE_CONFIG)
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
@@ -58,4 +63,8 @@ app
|
|||||||
.use(ToastService)
|
.use(ToastService)
|
||||||
.use(pinia)
|
.use(pinia)
|
||||||
.use(i18n)
|
.use(i18n)
|
||||||
|
.use(VueFire, {
|
||||||
|
firebaseApp,
|
||||||
|
modules: [VueFireAuth()]
|
||||||
|
})
|
||||||
.mount('#vue-app')
|
.mount('#vue-app')
|
||||||
|
|||||||
99
src/stores/firebaseAuthStore.ts
Normal file
99
src/stores/firebaseAuthStore.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import {
|
||||||
|
type Auth,
|
||||||
|
type User,
|
||||||
|
type UserCredential,
|
||||||
|
createUserWithEmailAndPassword,
|
||||||
|
onAuthStateChanged,
|
||||||
|
signInWithEmailAndPassword,
|
||||||
|
signOut
|
||||||
|
} from 'firebase/auth'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useFirebaseAuth } from 'vuefire'
|
||||||
|
|
||||||
|
export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||||
|
// State
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const currentUser = ref<User | null>(null)
|
||||||
|
const isInitialized = ref(false)
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
const isAuthenticated = computed(() => !!currentUser.value)
|
||||||
|
const userEmail = computed(() => currentUser.value?.email)
|
||||||
|
const userId = computed(() => currentUser.value?.uid)
|
||||||
|
|
||||||
|
// Get auth from VueFire and listen for auth state changes
|
||||||
|
const auth = useFirebaseAuth()
|
||||||
|
if (auth) {
|
||||||
|
onAuthStateChanged(auth, (user) => {
|
||||||
|
currentUser.value = user
|
||||||
|
isInitialized.value = true
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
error.value = 'Firebase Auth not available from VueFire'
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeAuthAction = async <T>(
|
||||||
|
action: (auth: Auth) => Promise<T>
|
||||||
|
): Promise<T> => {
|
||||||
|
if (!auth) throw new Error('Firebase Auth not initialized')
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await action(auth)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Unknown error'
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = async (
|
||||||
|
email: string,
|
||||||
|
password: string
|
||||||
|
): Promise<UserCredential> =>
|
||||||
|
executeAuthAction((authInstance) =>
|
||||||
|
signInWithEmailAndPassword(authInstance, email, password)
|
||||||
|
)
|
||||||
|
|
||||||
|
const register = async (
|
||||||
|
email: string,
|
||||||
|
password: string
|
||||||
|
): Promise<UserCredential> =>
|
||||||
|
executeAuthAction((authInstance) =>
|
||||||
|
createUserWithEmailAndPassword(authInstance, email, password)
|
||||||
|
)
|
||||||
|
|
||||||
|
const logout = async (): Promise<void> =>
|
||||||
|
executeAuthAction((authInstance) => signOut(authInstance))
|
||||||
|
|
||||||
|
const getIdToken = async (): Promise<string | null> => {
|
||||||
|
if (currentUser.value) {
|
||||||
|
return currentUser.value.getIdToken()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
currentUser,
|
||||||
|
isInitialized,
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
isAuthenticated,
|
||||||
|
userEmail,
|
||||||
|
userId,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
getIdToken
|
||||||
|
}
|
||||||
|
})
|
||||||
275
tests-ui/tests/store/firebaseAuthStore.test.ts
Normal file
275
tests-ui/tests/store/firebaseAuthStore.test.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import * as firebaseAuth from 'firebase/auth'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import * as vuefire from 'vuefire'
|
||||||
|
|
||||||
|
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||||
|
|
||||||
|
vi.mock('vuefire', () => ({
|
||||||
|
useFirebaseAuth: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('firebase/auth', () => ({
|
||||||
|
signInWithEmailAndPassword: vi.fn(),
|
||||||
|
createUserWithEmailAndPassword: vi.fn(),
|
||||||
|
signOut: vi.fn(),
|
||||||
|
onAuthStateChanged: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useFirebaseAuthStore', () => {
|
||||||
|
let store: ReturnType<typeof useFirebaseAuthStore>
|
||||||
|
let authStateCallback: (user: any) => void
|
||||||
|
|
||||||
|
const mockAuth = {
|
||||||
|
/* mock Auth object */
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockUser = {
|
||||||
|
uid: 'test-user-id',
|
||||||
|
email: 'test@example.com',
|
||||||
|
getIdToken: vi.fn().mockResolvedValue('mock-id-token')
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks()
|
||||||
|
|
||||||
|
// Mock useFirebaseAuth to return our mock auth object
|
||||||
|
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(mockAuth as any)
|
||||||
|
|
||||||
|
// Mock onAuthStateChanged to capture the callback and simulate initial auth state
|
||||||
|
vi.mocked(firebaseAuth.onAuthStateChanged).mockImplementation(
|
||||||
|
(_, callback) => {
|
||||||
|
authStateCallback = callback as (user: any) => void
|
||||||
|
// Call the callback with our mock user
|
||||||
|
;(callback as (user: any) => void)(mockUser)
|
||||||
|
// Return an unsubscribe function
|
||||||
|
return vi.fn()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initialize Pinia
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
store = useFirebaseAuthStore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize with the current user', () => {
|
||||||
|
expect(store.currentUser).toEqual(mockUser)
|
||||||
|
expect(store.isAuthenticated).toBe(true)
|
||||||
|
expect(store.userEmail).toBe('test@example.com')
|
||||||
|
expect(store.userId).toBe('test-user-id')
|
||||||
|
expect(store.loading).toBe(false)
|
||||||
|
expect(store.error).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should properly clean up error state between operations', async () => {
|
||||||
|
// First, cause an error
|
||||||
|
const mockError = new Error('Invalid password')
|
||||||
|
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockRejectedValueOnce(
|
||||||
|
mockError
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.login('test@example.com', 'wrong-password')
|
||||||
|
} catch (e) {
|
||||||
|
// Error expected
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(store.error).toBe('Invalid password')
|
||||||
|
|
||||||
|
// Now, succeed on next attempt
|
||||||
|
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValueOnce({
|
||||||
|
user: mockUser
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
await store.login('test@example.com', 'correct-password')
|
||||||
|
|
||||||
|
// Error should be cleared
|
||||||
|
expect(store.error).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle auth initialization failure', async () => {
|
||||||
|
// Mock auth as null to simulate initialization failure
|
||||||
|
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(null)
|
||||||
|
|
||||||
|
// Create a new store instance
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const uninitializedStore = useFirebaseAuthStore()
|
||||||
|
|
||||||
|
// Check that isInitialized is false
|
||||||
|
expect(uninitializedStore.isInitialized).toBe(false)
|
||||||
|
|
||||||
|
// Verify store actions throw appropriate errors
|
||||||
|
await expect(
|
||||||
|
uninitializedStore.login('test@example.com', 'password')
|
||||||
|
).rejects.toThrow('Firebase Auth not initialized')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('login', () => {
|
||||||
|
it('should login with valid credentials', async () => {
|
||||||
|
const mockUserCredential = { user: mockUser }
|
||||||
|
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
|
||||||
|
mockUserCredential as any
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await store.login('test@example.com', 'password')
|
||||||
|
|
||||||
|
expect(firebaseAuth.signInWithEmailAndPassword).toHaveBeenCalledWith(
|
||||||
|
mockAuth,
|
||||||
|
'test@example.com',
|
||||||
|
'password'
|
||||||
|
)
|
||||||
|
expect(result).toEqual(mockUserCredential)
|
||||||
|
expect(store.loading).toBe(false)
|
||||||
|
expect(store.error).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle login errors', async () => {
|
||||||
|
const mockError = new Error('Invalid password')
|
||||||
|
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockRejectedValue(
|
||||||
|
mockError
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
store.login('test@example.com', 'wrong-password')
|
||||||
|
).rejects.toThrow('Invalid password')
|
||||||
|
|
||||||
|
expect(firebaseAuth.signInWithEmailAndPassword).toHaveBeenCalledWith(
|
||||||
|
mockAuth,
|
||||||
|
'test@example.com',
|
||||||
|
'wrong-password'
|
||||||
|
)
|
||||||
|
expect(store.loading).toBe(false)
|
||||||
|
expect(store.error).toBe('Invalid password')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('register', () => {
|
||||||
|
it('should register a new user', async () => {
|
||||||
|
const mockUserCredential = { user: mockUser }
|
||||||
|
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue(
|
||||||
|
mockUserCredential as any
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await store.register('new@example.com', 'password')
|
||||||
|
|
||||||
|
expect(firebaseAuth.createUserWithEmailAndPassword).toHaveBeenCalledWith(
|
||||||
|
mockAuth,
|
||||||
|
'new@example.com',
|
||||||
|
'password'
|
||||||
|
)
|
||||||
|
expect(result).toEqual(mockUserCredential)
|
||||||
|
expect(store.loading).toBe(false)
|
||||||
|
expect(store.error).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle registration errors', async () => {
|
||||||
|
const mockError = new Error('Email already in use')
|
||||||
|
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockRejectedValue(
|
||||||
|
mockError
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
store.register('existing@example.com', 'password')
|
||||||
|
).rejects.toThrow('Email already in use')
|
||||||
|
|
||||||
|
expect(firebaseAuth.createUserWithEmailAndPassword).toHaveBeenCalledWith(
|
||||||
|
mockAuth,
|
||||||
|
'existing@example.com',
|
||||||
|
'password'
|
||||||
|
)
|
||||||
|
expect(store.loading).toBe(false)
|
||||||
|
expect(store.error).toBe('Email already in use')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle concurrent login attempts correctly', async () => {
|
||||||
|
// Set up multiple login promises
|
||||||
|
const mockUserCredential = { user: mockUser }
|
||||||
|
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
|
||||||
|
mockUserCredential as any
|
||||||
|
)
|
||||||
|
|
||||||
|
const loginPromise1 = store.login('user1@example.com', 'password1')
|
||||||
|
const loginPromise2 = store.login('user2@example.com', 'password2')
|
||||||
|
|
||||||
|
// Resolve both promises
|
||||||
|
await Promise.all([loginPromise1, loginPromise2])
|
||||||
|
|
||||||
|
// Verify the loading state is reset
|
||||||
|
expect(store.loading).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('logout', () => {
|
||||||
|
it('should sign out the user', async () => {
|
||||||
|
vi.mocked(firebaseAuth.signOut).mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
await store.logout()
|
||||||
|
|
||||||
|
expect(firebaseAuth.signOut).toHaveBeenCalledWith(mockAuth)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle logout errors', async () => {
|
||||||
|
const mockError = new Error('Network error')
|
||||||
|
vi.mocked(firebaseAuth.signOut).mockRejectedValue(mockError)
|
||||||
|
|
||||||
|
await expect(store.logout()).rejects.toThrow('Network error')
|
||||||
|
|
||||||
|
expect(firebaseAuth.signOut).toHaveBeenCalledWith(mockAuth)
|
||||||
|
expect(store.error).toBe('Network error')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getIdToken', () => {
|
||||||
|
it('should return the user ID token', async () => {
|
||||||
|
// FIX 2: Reset the mock and set a specific return value
|
||||||
|
mockUser.getIdToken.mockReset()
|
||||||
|
mockUser.getIdToken.mockResolvedValue('mock-id-token')
|
||||||
|
|
||||||
|
const token = await store.getIdToken()
|
||||||
|
|
||||||
|
expect(mockUser.getIdToken).toHaveBeenCalled()
|
||||||
|
expect(token).toBe('mock-id-token')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null when no user is logged in', async () => {
|
||||||
|
// Simulate logged out state
|
||||||
|
authStateCallback(null)
|
||||||
|
|
||||||
|
const token = await store.getIdToken()
|
||||||
|
|
||||||
|
expect(token).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null for token after login and logout sequence', async () => {
|
||||||
|
// Setup mock for login
|
||||||
|
const mockUserCredential = { user: mockUser }
|
||||||
|
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
|
||||||
|
mockUserCredential as any
|
||||||
|
)
|
||||||
|
|
||||||
|
// Login
|
||||||
|
await store.login('test@example.com', 'password')
|
||||||
|
|
||||||
|
// Simulate successful auth state update after login
|
||||||
|
authStateCallback(mockUser)
|
||||||
|
|
||||||
|
// Verify we're logged in and can get a token
|
||||||
|
mockUser.getIdToken.mockReset()
|
||||||
|
mockUser.getIdToken.mockResolvedValue('mock-id-token')
|
||||||
|
expect(await store.getIdToken()).toBe('mock-id-token')
|
||||||
|
|
||||||
|
// Setup mock for logout
|
||||||
|
vi.mocked(firebaseAuth.signOut).mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
await store.logout()
|
||||||
|
|
||||||
|
// Simulate successful auth state update after logout
|
||||||
|
authStateCallback(null)
|
||||||
|
|
||||||
|
// Verify token is null after logout
|
||||||
|
const tokenAfterLogout = await store.getIdToken()
|
||||||
|
expect(tokenAfterLogout).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user