mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-19 14:30:07 +00:00
Wire checkout attribution into GTM events and checkout POST payloads.
This updates the cloud telemetry flow so the backend team can correlate checkout events without relying on frontend cookie parsing. We now surface GA4 identity via a GTM-provided global and include attribution on both `begin_checkout` telemetry and the checkout POST body. The backend should continue to derive the Firebase UID from the auth header; the checkout POST body does not include a user ID.
GTM events pushed (unchanged list, updated payloads):
- `page_view` (page title/location/referrer as before)
- `sign_up` / `login`
- `begin_checkout` now includes:
- `user_id`, `tier`, `cycle`, `checkout_type`, `previous_tier` (if change flow)
- `ga_client_id`, `ga_session_id`, `ga_session_number`
- `gclid`, `gbraid`, `wbraid`
Backend-facing change:
- `POST /customers/cloud-subscription-checkout/:tier` now includes a JSON body with attribution fields only:
- `ga_client_id`, `ga_session_id`, `ga_session_number`
- `gclid`, `gbraid`, `wbraid`
- Backend should continue to derive the Firebase UID from the auth header.
Required GTM setup:
- Provide `window.__ga_identity__` via a GTM Custom HTML tag (after GA4/Google tag) with `{ client_id, session_id, session_number }`. The frontend reads this to populate the GA fields.
<img width="1416" height="1230" alt="image" src="https://github.com/user-attachments/assets/b77cf0ed-be69-4497-a540-86e5beb7bfac" />
## Screenshots (if applicable)
<img width="991" height="385" alt="image" src="https://github.com/user-attachments/assets/8309cd9e-5ab5-4fba-addb-2d101aaae7e9"/>
Manual Testing:
<img width="3839" height="2020" alt="image" src="https://github.com/user-attachments/assets/36901dfd-08db-4c07-97b8-a71e6783c72f"/>
<img width="2141" height="851" alt="image" src="https://github.com/user-attachments/assets/2e9f7aa4-4716-40f7-b147-1c74b0ce8067"/>
<img width="2298" height="982" alt="image" src="https://github.com/user-attachments/assets/72cbaa53-9b92-458a-8539-c987cf753b02"/>
<img width="2125" height="999" alt="image" src="https://github.com/user-attachments/assets/4b22387e-8027-4f50-be49-a410282a1adc"/>
To manually test, you will need to override api/features in devtools to also return this:
```
"gtm_container_id": "GTM-NP9JM6K7"
```
┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8354-fix-route-gtm-through-telemetry-entrypoint-2f66d73d36508138afacdeffe835f28a) by [Unito](https://www.unito.io)
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit
* **New Features**
* Analytics expanded: page view tracking, richer auth telemetry (includes user IDs), and checkout begin events with attribution.
* Google Tag Manager support and persistent checkout attribution (GA/client/session IDs, gclid/gbraid/wbraid).
* **Chores**
* Telemetry reworked to support multiple providers via a registry with cloud-only initialization.
* Workflow module refactored for clearer exports.
* **Tests**
* Added/updated tests for attribution, telemetry, and subscription flows.
* **CI**
* New check prevents telemetry from leaking into distribution artifacts.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
539 lines
15 KiB
TypeScript
539 lines
15 KiB
TypeScript
import { FirebaseError } from 'firebase/app'
|
|
import {
|
|
AuthErrorCodes,
|
|
GithubAuthProvider,
|
|
GoogleAuthProvider,
|
|
browserLocalPersistence,
|
|
createUserWithEmailAndPassword,
|
|
getAdditionalUserInfo,
|
|
onAuthStateChanged,
|
|
onIdTokenChanged,
|
|
sendPasswordResetEmail,
|
|
setPersistence,
|
|
signInWithEmailAndPassword,
|
|
signInWithPopup,
|
|
signOut,
|
|
updatePassword
|
|
} from 'firebase/auth'
|
|
import type { Auth, User, UserCredential } from 'firebase/auth'
|
|
import { defineStore } from 'pinia'
|
|
import { computed, ref } from 'vue'
|
|
import { useFirebaseAuth } from 'vuefire'
|
|
|
|
import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
|
import { t } from '@/i18n'
|
|
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
|
|
import { isCloud } from '@/platform/distribution/types'
|
|
import { useTelemetry } from '@/platform/telemetry'
|
|
import { useDialogService } from '@/services/dialogService'
|
|
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
|
import type { AuthHeader } from '@/types/authTypes'
|
|
import type { operations } from '@/types/comfyRegistryTypes'
|
|
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
|
|
|
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 type BillingPortalTargetTier = NonNullable<
|
|
NonNullable<
|
|
NonNullable<AccessBillingPortalReqBody>['content']
|
|
>['application/json']
|
|
>['target_tier']
|
|
|
|
export class FirebaseAuthStoreError extends Error {
|
|
constructor(message: string) {
|
|
super(message)
|
|
this.name = 'FirebaseAuthStoreError'
|
|
}
|
|
}
|
|
|
|
export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
|
const { flags } = useFeatureFlags()
|
|
|
|
// 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)
|
|
|
|
// Token refresh trigger - increments when token is refreshed
|
|
const tokenRefreshTrigger = ref(0)
|
|
/**
|
|
* The user ID for which the initial ID token has been observed.
|
|
* When a token changes for the same user, that is a refresh.
|
|
*/
|
|
const lastTokenUserId = ref<string | null>(null)
|
|
|
|
const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}`
|
|
|
|
// Providers
|
|
const googleProvider = new GoogleAuthProvider()
|
|
googleProvider.addScope('email')
|
|
googleProvider.setCustomParameters({
|
|
prompt: 'select_account'
|
|
})
|
|
const githubProvider = new GithubAuthProvider()
|
|
githubProvider.addScope('user:email')
|
|
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
|
|
if (user === null) {
|
|
lastTokenUserId.value = null
|
|
|
|
// Clear workspace sessionStorage on logout to prevent stale tokens
|
|
try {
|
|
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
|
|
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.TOKEN)
|
|
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
|
|
} catch {
|
|
// Ignore sessionStorage errors (e.g., in private browsing mode)
|
|
}
|
|
}
|
|
|
|
// Reset balance when auth state changes
|
|
balance.value = null
|
|
lastBalanceUpdateTime.value = null
|
|
})
|
|
|
|
// Listen for token refresh events
|
|
onIdTokenChanged(auth, (user) => {
|
|
if (user && isCloud) {
|
|
// Skip initial token change
|
|
if (lastTokenUserId.value !== user.uid) {
|
|
lastTokenUserId.value = user.uid
|
|
return
|
|
}
|
|
tokenRefreshTrigger.value++
|
|
}
|
|
})
|
|
|
|
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. Workspace token (if team_workspaces_enabled and user has active workspace context)
|
|
* 2. Firebase authentication token (if user is logged in)
|
|
* 3. API key (if stored in the browser's credential manager)
|
|
*
|
|
* @returns {Promise<AuthHeader | null>}
|
|
* - A LoggedInAuthHeader with Bearer token (workspace or Firebase)
|
|
* - An ApiKeyAuthHeader with X-API-KEY if API key exists
|
|
* - null if no authentication method is available
|
|
*/
|
|
const getAuthHeader = async (): Promise<AuthHeader | null> => {
|
|
if (flags.teamWorkspacesEnabled) {
|
|
const workspaceToken = sessionStorage.getItem(
|
|
WORKSPACE_STORAGE_KEYS.TOKEN
|
|
)
|
|
const expiresAt = sessionStorage.getItem(
|
|
WORKSPACE_STORAGE_KEYS.EXPIRES_AT
|
|
)
|
|
|
|
if (workspaceToken && expiresAt) {
|
|
const expiryTime = parseInt(expiresAt, 10)
|
|
if (Date.now() < expiryTime) {
|
|
return {
|
|
Authorization: `Bearer ${workspaceToken}`
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const token = await getIdToken()
|
|
if (token) {
|
|
return {
|
|
Authorization: `Bearer ${token}`
|
|
}
|
|
}
|
|
|
|
return useApiKeyAuthStore().getAuthHeader()
|
|
}
|
|
|
|
/**
|
|
* Returns Firebase auth header for user-scoped endpoints (e.g., /customers/*).
|
|
* Use this for endpoints that need user identity, not workspace context.
|
|
*/
|
|
const getFirebaseAuthHeader = async (): Promise<AuthHeader | null> => {
|
|
const token = await getIdToken()
|
|
return token ? { Authorization: `Bearer ${token}` } : null
|
|
}
|
|
|
|
/**
|
|
* Returns the raw auth token (not wrapped in a header object).
|
|
* Priority: workspace token > Firebase token.
|
|
* Use this for WebSocket connections and backend node auth.
|
|
*/
|
|
const getAuthToken = async (): Promise<string | undefined> => {
|
|
if (flags.teamWorkspacesEnabled) {
|
|
const workspaceToken = sessionStorage.getItem(
|
|
WORKSPACE_STORAGE_KEYS.TOKEN
|
|
)
|
|
const expiresAt = sessionStorage.getItem(
|
|
WORKSPACE_STORAGE_KEYS.EXPIRES_AT
|
|
)
|
|
|
|
if (workspaceToken && expiresAt) {
|
|
const expiryTime = parseInt(expiresAt, 10)
|
|
if (Date.now() < expiryTime) {
|
|
return workspaceToken
|
|
}
|
|
}
|
|
}
|
|
|
|
return await getIdToken()
|
|
}
|
|
|
|
const fetchBalance = async (): Promise<GetCustomerBalanceResponse | null> => {
|
|
isFetchingBalance.value = true
|
|
try {
|
|
const authHeader = await getFirebaseAuthHeader()
|
|
if (!authHeader) {
|
|
throw new FirebaseAuthStoreError(
|
|
t('toastMessages.userNotAuthenticated')
|
|
)
|
|
}
|
|
|
|
const response = await fetch(buildApiUrl('/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 getFirebaseAuthHeader()
|
|
if (!authHeader) {
|
|
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
|
}
|
|
|
|
const createCustomerRes = await fetch(buildApiUrl('/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> => {
|
|
const result = await executeAuthAction(
|
|
(authInstance) =>
|
|
signInWithEmailAndPassword(authInstance, email, password),
|
|
{ createCustomer: true }
|
|
)
|
|
|
|
if (isCloud) {
|
|
useTelemetry()?.trackAuth({
|
|
method: 'email',
|
|
is_new_user: false,
|
|
user_id: result.user.uid
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
const register = async (
|
|
email: string,
|
|
password: string
|
|
): Promise<UserCredential> => {
|
|
const result = await executeAuthAction(
|
|
(authInstance) =>
|
|
createUserWithEmailAndPassword(authInstance, email, password),
|
|
{ createCustomer: true }
|
|
)
|
|
|
|
if (isCloud) {
|
|
useTelemetry()?.trackAuth({
|
|
method: 'email',
|
|
is_new_user: true,
|
|
user_id: result.user.uid
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
const loginWithGoogle = async (): Promise<UserCredential> => {
|
|
const result = await executeAuthAction(
|
|
(authInstance) => signInWithPopup(authInstance, googleProvider),
|
|
{ createCustomer: true }
|
|
)
|
|
|
|
if (isCloud) {
|
|
const additionalUserInfo = getAdditionalUserInfo(result)
|
|
const isNewUser = additionalUserInfo?.isNewUser ?? false
|
|
useTelemetry()?.trackAuth({
|
|
method: 'google',
|
|
is_new_user: isNewUser,
|
|
user_id: result.user.uid
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
const loginWithGithub = async (): Promise<UserCredential> => {
|
|
const result = await executeAuthAction(
|
|
(authInstance) => signInWithPopup(authInstance, githubProvider),
|
|
{ createCustomer: true }
|
|
)
|
|
|
|
if (isCloud) {
|
|
const additionalUserInfo = getAdditionalUserInfo(result)
|
|
const isNewUser = additionalUserInfo?.isNewUser ?? false
|
|
useTelemetry()?.trackAuth({
|
|
method: 'github',
|
|
is_new_user: isNewUser,
|
|
user_id: result.user.uid
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
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 getFirebaseAuthHeader()
|
|
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(buildApiUrl('/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 (
|
|
targetTier?: BillingPortalTargetTier
|
|
): Promise<AccessBillingPortalResponse> => {
|
|
const authHeader = await getFirebaseAuthHeader()
|
|
if (!authHeader) {
|
|
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
|
}
|
|
|
|
const response = await fetch(buildApiUrl('/customers/billing'), {
|
|
method: 'POST',
|
|
headers: {
|
|
...authHeader,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
...(targetTier && {
|
|
body: JSON.stringify({ target_tier: targetTier })
|
|
})
|
|
})
|
|
|
|
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,
|
|
tokenRefreshTrigger,
|
|
|
|
// Getters
|
|
isAuthenticated,
|
|
userEmail,
|
|
userId,
|
|
|
|
// Actions
|
|
login,
|
|
register,
|
|
logout,
|
|
createCustomer,
|
|
getIdToken,
|
|
loginWithGoogle,
|
|
loginWithGithub,
|
|
initiateCreditPurchase,
|
|
fetchBalance,
|
|
accessBillingPortal,
|
|
sendPasswordReset,
|
|
updatePassword: _updatePassword,
|
|
getAuthHeader,
|
|
getFirebaseAuthHeader,
|
|
getAuthToken
|
|
}
|
|
})
|