mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-05 15:40:10 +00:00
* [fix] gracefully handle Firebase auth failure * [test] Add failing tests to reproduce Firebase Auth network issue #4468 Add test cases that demonstrate the current problematic behavior where Firebase Auth makes network requests when offline without graceful error handling, causing toast error messages and degraded offline experience. Tests reproduce: - getIdToken() throwing auth/network-request-failed instead of returning null - getAuthHeader() failing to fallback gracefully when Firebase token refresh fails These tests currently pass by expecting the error to be thrown. After implementing the fix, the tests should be updated to verify graceful handling (returning null instead of throwing). Related to issue #4468: Firebase Auth makes network requests when offline without evicting token 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * [test] update firebaseAuthStore tests They match the behavior of the implemented solution now * [test] add firebaseAuthStore.getTokenId test for non-network errors * [chore] code review feedback * [test] use FirebaseError Co-authored-by: Alexander Brown <drjkl@comfy.org> * [fix] remove indentation and fix test --------- Co-authored-by: snomiao <snomiao@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Alexander Brown <drjkl@comfy.org>
391 lines
11 KiB
TypeScript
391 lines
11 KiB
TypeScript
import { FirebaseError } from 'firebase/app'
|
|
import {
|
|
type Auth,
|
|
AuthErrorCodes,
|
|
GithubAuthProvider,
|
|
GoogleAuthProvider,
|
|
type User,
|
|
type UserCredential,
|
|
browserLocalPersistence,
|
|
createUserWithEmailAndPassword,
|
|
onAuthStateChanged,
|
|
sendPasswordResetEmail,
|
|
setPersistence,
|
|
signInWithEmailAndPassword,
|
|
signInWithPopup,
|
|
signOut,
|
|
updatePassword
|
|
} from 'firebase/auth'
|
|
import { defineStore } from 'pinia'
|
|
import { computed, ref } from 'vue'
|
|
import { useFirebaseAuth } from 'vuefire'
|
|
|
|
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
|
|
import { t } from '@/i18n'
|
|
import { useDialogService } from '@/services/dialogService'
|
|
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
|
import { type AuthHeader } from '@/types/authTypes'
|
|
import { operations } from '@/types/comfyRegistryTypes'
|
|
|
|
type CreditPurchaseResponse =
|
|
operations['InitiateCreditPurchase']['responses']['201']['content']['application/json']
|
|
type CreditPurchasePayload =
|
|
operations['InitiateCreditPurchase']['requestBody']['content']['application/json']
|
|
type CreateCustomerResponse =
|
|
operations['createCustomer']['responses']['201']['content']['application/json']
|
|
type GetCustomerBalanceResponse =
|
|
operations['GetCustomerBalance']['responses']['200']['content']['application/json']
|
|
type AccessBillingPortalResponse =
|
|
operations['AccessBillingPortal']['responses']['200']['content']['application/json']
|
|
type AccessBillingPortalReqBody =
|
|
operations['AccessBillingPortal']['requestBody']
|
|
|
|
export class FirebaseAuthStoreError extends Error {
|
|
constructor(message: string) {
|
|
super(message)
|
|
this.name = 'FirebaseAuthStoreError'
|
|
}
|
|
}
|
|
|
|
export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
|
// State
|
|
const loading = ref(false)
|
|
const currentUser = ref<User | null>(null)
|
|
const isInitialized = ref(false)
|
|
const customerCreated = ref(false)
|
|
const isFetchingBalance = ref(false)
|
|
|
|
// Balance state
|
|
const balance = ref<GetCustomerBalanceResponse | null>(null)
|
|
const lastBalanceUpdateTime = ref<Date | null>(null)
|
|
|
|
// Providers
|
|
const googleProvider = new GoogleAuthProvider()
|
|
googleProvider.setCustomParameters({
|
|
prompt: 'select_account'
|
|
})
|
|
const githubProvider = new GithubAuthProvider()
|
|
githubProvider.setCustomParameters({
|
|
prompt: 'select_account'
|
|
})
|
|
|
|
// 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
|
|
// From useFirebaseAuth docs:
|
|
// Retrieves the Firebase Auth instance. Returns `null` on the server.
|
|
// When using this function on the client in TypeScript, you can force the type with `useFirebaseAuth()!`.
|
|
const auth = useFirebaseAuth()!
|
|
// Set persistence to localStorage (works in both browser and Electron)
|
|
void setPersistence(auth, browserLocalPersistence)
|
|
|
|
onAuthStateChanged(auth, (user) => {
|
|
currentUser.value = user
|
|
isInitialized.value = true
|
|
|
|
// Reset balance when auth state changes
|
|
balance.value = null
|
|
lastBalanceUpdateTime.value = null
|
|
})
|
|
|
|
const getIdToken = async (): Promise<string | undefined> => {
|
|
if (!currentUser.value) return
|
|
try {
|
|
return await currentUser.value.getIdToken()
|
|
} catch (error: unknown) {
|
|
if (
|
|
error instanceof FirebaseError &&
|
|
error.code === AuthErrorCodes.NETWORK_REQUEST_FAILED
|
|
) {
|
|
console.warn(
|
|
'Could not authenticate with Firebase. Features requiring authentication might not work.'
|
|
)
|
|
return
|
|
}
|
|
|
|
useDialogService().showErrorDialog(error, {
|
|
title: t('errorDialog.defaultTitle'),
|
|
reportType: 'authenticationError'
|
|
})
|
|
console.error(error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieves the appropriate authentication header for API requests.
|
|
* Checks for authentication in the following order:
|
|
* 1. Firebase authentication token (if user is logged in)
|
|
* 2. API key (if stored in the browser's credential manager)
|
|
*
|
|
* @returns {Promise<AuthHeader | null>}
|
|
* - A LoggedInAuthHeader with Bearer token if Firebase authenticated
|
|
* - An ApiKeyAuthHeader with X-API-KEY if API key exists
|
|
* - null if neither authentication method is available
|
|
*/
|
|
const getAuthHeader = async (): Promise<AuthHeader | null> => {
|
|
// If available, set header with JWT used to identify the user to Firebase service
|
|
const token = await getIdToken()
|
|
if (token) {
|
|
return {
|
|
Authorization: `Bearer ${token}`
|
|
}
|
|
}
|
|
|
|
// If not authenticated with Firebase, try falling back to API key if available
|
|
return useApiKeyAuthStore().getAuthHeader()
|
|
}
|
|
|
|
const fetchBalance = async (): Promise<GetCustomerBalanceResponse | null> => {
|
|
isFetchingBalance.value = true
|
|
try {
|
|
const authHeader = await getAuthHeader()
|
|
if (!authHeader) {
|
|
throw new FirebaseAuthStoreError(
|
|
t('toastMessages.userNotAuthenticated')
|
|
)
|
|
}
|
|
|
|
const response = await fetch(`${COMFY_API_BASE_URL}/customers/balance`, {
|
|
headers: {
|
|
...authHeader,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
})
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 404) {
|
|
// Customer not found is expected for new users
|
|
return null
|
|
}
|
|
const errorData = await response.json()
|
|
throw new FirebaseAuthStoreError(
|
|
t('toastMessages.failedToFetchBalance', {
|
|
error: errorData.message
|
|
})
|
|
)
|
|
}
|
|
|
|
const balanceData = await response.json()
|
|
// Update the last balance update time
|
|
lastBalanceUpdateTime.value = new Date()
|
|
balance.value = balanceData
|
|
return balanceData
|
|
} finally {
|
|
isFetchingBalance.value = false
|
|
}
|
|
}
|
|
|
|
const createCustomer = async (): Promise<CreateCustomerResponse> => {
|
|
const authHeader = await getAuthHeader()
|
|
if (!authHeader) {
|
|
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
|
}
|
|
|
|
const createCustomerRes = await fetch(`${COMFY_API_BASE_URL}/customers`, {
|
|
method: 'POST',
|
|
headers: {
|
|
...authHeader,
|
|
'Content-Type': 'application/json'
|
|
}
|
|
})
|
|
if (!createCustomerRes.ok) {
|
|
throw new FirebaseAuthStoreError(
|
|
t('toastMessages.failedToCreateCustomer', {
|
|
error: createCustomerRes.statusText
|
|
})
|
|
)
|
|
}
|
|
|
|
const createCustomerResJson: CreateCustomerResponse =
|
|
await createCustomerRes.json()
|
|
if (!createCustomerResJson?.id) {
|
|
throw new FirebaseAuthStoreError(
|
|
t('toastMessages.failedToCreateCustomer', {
|
|
error: 'No customer ID returned'
|
|
})
|
|
)
|
|
}
|
|
|
|
return createCustomerResJson
|
|
}
|
|
|
|
const executeAuthAction = async <T>(
|
|
action: (auth: Auth) => Promise<T>,
|
|
options: {
|
|
createCustomer?: boolean
|
|
} = {}
|
|
): Promise<T> => {
|
|
loading.value = true
|
|
|
|
try {
|
|
const result = await action(auth)
|
|
|
|
// Create customer if needed
|
|
if (options?.createCustomer) {
|
|
const token = await getIdToken()
|
|
if (!token) {
|
|
throw new Error('Cannot create customer: User not authenticated')
|
|
}
|
|
await createCustomer()
|
|
}
|
|
|
|
return result
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const login = async (
|
|
email: string,
|
|
password: string
|
|
): Promise<UserCredential> =>
|
|
executeAuthAction(
|
|
(authInstance) =>
|
|
signInWithEmailAndPassword(authInstance, email, password),
|
|
{ createCustomer: true }
|
|
)
|
|
|
|
const register = async (
|
|
email: string,
|
|
password: string
|
|
): Promise<UserCredential> => {
|
|
return executeAuthAction(
|
|
(authInstance) =>
|
|
createUserWithEmailAndPassword(authInstance, email, password),
|
|
{ createCustomer: true }
|
|
)
|
|
}
|
|
|
|
const loginWithGoogle = async (): Promise<UserCredential> =>
|
|
executeAuthAction(
|
|
(authInstance) => signInWithPopup(authInstance, googleProvider),
|
|
{ createCustomer: true }
|
|
)
|
|
|
|
const loginWithGithub = async (): Promise<UserCredential> =>
|
|
executeAuthAction(
|
|
(authInstance) => signInWithPopup(authInstance, githubProvider),
|
|
{ createCustomer: true }
|
|
)
|
|
|
|
const logout = async (): Promise<void> =>
|
|
executeAuthAction((authInstance) => signOut(authInstance))
|
|
|
|
const sendPasswordReset = async (email: string): Promise<void> =>
|
|
executeAuthAction((authInstance) =>
|
|
sendPasswordResetEmail(authInstance, email)
|
|
)
|
|
|
|
/** Update password for current user */
|
|
const _updatePassword = async (newPassword: string): Promise<void> => {
|
|
if (!currentUser.value) {
|
|
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
|
}
|
|
await updatePassword(currentUser.value, newPassword)
|
|
}
|
|
|
|
const addCredits = async (
|
|
requestBodyContent: CreditPurchasePayload
|
|
): Promise<CreditPurchaseResponse> => {
|
|
const authHeader = await getAuthHeader()
|
|
if (!authHeader) {
|
|
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
|
}
|
|
|
|
// Ensure customer was created during login/registration
|
|
if (!customerCreated.value) {
|
|
await createCustomer()
|
|
customerCreated.value = true
|
|
}
|
|
|
|
const response = await fetch(`${COMFY_API_BASE_URL}/customers/credit`, {
|
|
method: 'POST',
|
|
headers: {
|
|
...authHeader,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(requestBodyContent)
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json()
|
|
throw new FirebaseAuthStoreError(
|
|
t('toastMessages.failedToInitiateCreditPurchase', {
|
|
error: errorData.message
|
|
})
|
|
)
|
|
}
|
|
|
|
return response.json()
|
|
}
|
|
|
|
const initiateCreditPurchase = async (
|
|
requestBodyContent: CreditPurchasePayload
|
|
): Promise<CreditPurchaseResponse> =>
|
|
executeAuthAction((_) => addCredits(requestBodyContent))
|
|
|
|
const accessBillingPortal = async (
|
|
requestBody?: AccessBillingPortalReqBody
|
|
): Promise<AccessBillingPortalResponse> => {
|
|
const authHeader = await getAuthHeader()
|
|
if (!authHeader) {
|
|
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
|
}
|
|
|
|
const response = await fetch(`${COMFY_API_BASE_URL}/customers/billing`, {
|
|
method: 'POST',
|
|
headers: {
|
|
...authHeader,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
...(requestBody && {
|
|
body: JSON.stringify(requestBody)
|
|
})
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json()
|
|
throw new FirebaseAuthStoreError(
|
|
t('toastMessages.failedToAccessBillingPortal', {
|
|
error: errorData.message
|
|
})
|
|
)
|
|
}
|
|
|
|
return response.json()
|
|
}
|
|
|
|
return {
|
|
// State
|
|
loading,
|
|
currentUser,
|
|
isInitialized,
|
|
balance,
|
|
lastBalanceUpdateTime,
|
|
isFetchingBalance,
|
|
|
|
// Getters
|
|
isAuthenticated,
|
|
userEmail,
|
|
userId,
|
|
|
|
// Actions
|
|
login,
|
|
register,
|
|
logout,
|
|
createCustomer,
|
|
getIdToken,
|
|
loginWithGoogle,
|
|
loginWithGithub,
|
|
initiateCreditPurchase,
|
|
fetchBalance,
|
|
accessBillingPortal,
|
|
sendPasswordReset,
|
|
updatePassword: _updatePassword,
|
|
getAuthHeader
|
|
}
|
|
})
|