Compare commits

...

4 Commits

Author SHA1 Message Date
Hunter Senft-Grupp
e8423f64ca feat: route all billing through cloud server
- useBillingContext always returns 'workspace' type when team workspaces
  enabled (no personal workspace special case)
- getComfyApiBaseUrl returns '' in cloud mode to proxy through cloud
- comfyRegistryService uses getComfyApiBaseUrl instead of hardcoded URL
- Remove createCustomer from all auth actions (no legacy Stripe customers)
- useSubscriptionDialog simplified to just teamWorkspacesEnabled flag
2026-02-07 22:31:03 -08:00
Alexander Brown
2eb7b8c994 Add @Comfy-org/comfy_frontend_devs to CODEOWNERS (#8739)
Updated CODEOWNERS file to include @Comfy-org/comfy_frontend_devs as an
owner for multiple paths.

## Summary

<!-- One sentence describing what changed and why. -->

## Changes

- **What**: <!-- Core functionality added/modified -->
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->
2026-02-07 20:52:46 -08:00
Christian Byrne
3eccf3ec61 feat: default to Getting Started category for new users in templates modal (#8599)
## Summary

Updates the templates modal to default to the "Getting Started" category
for new users.

## Changes

- Add `initialCategory` prop to `WorkflowTemplateSelectorDialog`
component
- Integrate `useNewUserService` in the dialog composable to detect
first-time users
- New users automatically see the "basics-getting-started" category
- Existing users continue to see "all" templates as default
- Allow explicit category override via options parameter

## Testing

- Added unit tests covering all scenarios (new user, non-new user,
undetermined, explicit override)
- 6 tests pass

Fixes COM-9146

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8599-feat-default-to-Getting-Started-category-for-new-users-in-templates-modal-2fd6d73d365081d4a5fad2abdb768269)
by [Unito](https://www.unito.io)
2026-02-07 20:30:10 -08:00
Hunter
1b73b5b31e fix: show credit balance for unsubscribed personal workspaces (#8719)
## Summary

Credit balance was not displayed in the user popover for personal
workspace users without an active subscription. The `displayedCredits`
computed returned `"0"` and `refreshBalance` skipped the API call when
there was no active subscription, hiding any existing balance.

## Changes

- **What**: Remove subscription-gated guards in
`CurrentUserPopoverWorkspace.vue`:
- `displayedCredits`: no longer returns early `""` / `"0"` when
subscription is null or inactive — always reads from the balance API
response
- `refreshBalance`: always fetches balance on popover open regardless of
subscription status

## Review Focus

The credits section visibility is already gated by `showCreditsSection`
(personal workspace or owner role). This change only affects what value
is displayed and whether the balance API is called — it does not change
who sees the section.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8719-fix-show-credit-balance-for-unsubscribed-personal-workspaces-3006d73d3650812e9d70e5a8629c5f60)
by [Unito](https://www.unito.io)
2026-02-07 20:22:00 -08:00
11 changed files with 218 additions and 79 deletions

View File

@@ -2,57 +2,57 @@
* @Comfy-org/comfy_frontend_devs
# Desktop/Electron
/apps/desktop-ui/ @benceruleanlu
/src/stores/electronDownloadStore.ts @benceruleanlu
/src/extensions/core/electronAdapter.ts @benceruleanlu
/vite.electron.config.mts @benceruleanlu
/apps/desktop-ui/ @benceruleanlu @Comfy-org/comfy_frontend_devs
/src/stores/electronDownloadStore.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
/src/extensions/core/electronAdapter.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
/vite.electron.config.mts @benceruleanlu @Comfy-org/comfy_frontend_devs
# Common UI Components
/src/components/chip/ @viva-jinyi
/src/components/card/ @viva-jinyi
/src/components/button/ @viva-jinyi
/src/components/input/ @viva-jinyi
/src/components/chip/ @viva-jinyi @Comfy-org/comfy_frontend_devs
/src/components/card/ @viva-jinyi @Comfy-org/comfy_frontend_devs
/src/components/button/ @viva-jinyi @Comfy-org/comfy_frontend_devs
/src/components/input/ @viva-jinyi @Comfy-org/comfy_frontend_devs
# Topbar
/src/components/topbar/ @pythongosssss
/src/components/topbar/ @pythongosssss @Comfy-org/comfy_frontend_devs
# Thumbnail
/src/renderer/core/thumbnail/ @pythongosssss
/src/renderer/core/thumbnail/ @pythongosssss @Comfy-org/comfy_frontend_devs
# Legacy UI
/scripts/ui/ @pythongosssss
/scripts/ui/ @pythongosssss @Comfy-org/comfy_frontend_devs
# Link rendering
/src/renderer/core/canvas/links/ @benceruleanlu
/src/renderer/core/canvas/links/ @benceruleanlu @Comfy-org/comfy_frontend_devs
# Partner Nodes
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88 @Comfy-org/comfy_frontend_devs
# Node help system
/src/utils/nodeHelpUtil.ts @benceruleanlu
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu
/src/services/nodeHelpService.ts @benceruleanlu
/src/utils/nodeHelpUtil.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
/src/services/nodeHelpService.ts @benceruleanlu @Comfy-org/comfy_frontend_devs
# Selection toolbox
/src/components/graph/selectionToolbox/ @Myestery
/src/components/graph/selectionToolbox/ @Myestery @Comfy-org/comfy_frontend_devs
# Minimap
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery @Comfy-org/comfy_frontend_devs
# Workflow Templates
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki @Comfy-org/comfy_frontend_devs
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki @Comfy-org/comfy_frontend_devs
# Mask Editor
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @Comfy-org/comfy_frontend_devs
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @Comfy-org/comfy_frontend_devs
# 3D
/src/extensions/core/load3d.ts @jtydhr88
/src/components/load3d/ @jtydhr88
/src/extensions/core/load3d.ts @jtydhr88 @Comfy-org/comfy_frontend_devs
/src/components/load3d/ @jtydhr88 @Comfy-org/comfy_frontend_devs
# Manager
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata @Comfy-org/comfy_frontend_devs
# Translations
/src/locales/ @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs

View File

@@ -422,8 +422,9 @@ import { createGridStyle } from '@/utils/gridUtil'
const { t } = useI18n()
const { onClose: originalOnClose } = defineProps<{
const { onClose: originalOnClose, initialCategory = 'all' } = defineProps<{
onClose: () => void
initialCategory?: string
}>()
// Track session time for telemetry
@@ -547,7 +548,7 @@ const allTemplates = computed(() => {
})
// Navigation
const selectedNavItem = ref<string | null>('all')
const selectedNavItem = ref<string | null>(initialCategory)
// Filter templates based on selected navigation item
const navigationFilteredTemplates = computed(() => {

View File

@@ -254,9 +254,6 @@ const isLoadingBalance = isLoading
const displayedCredits = computed(() => {
if (initState.value !== 'ready') return ''
// Wait for subscription to load
if (subscription.value === null) return ''
if (!isActiveSubscription.value) return '0'
// API field is named _micros but contains cents (naming inconsistency)
const cents =
@@ -343,9 +340,7 @@ const toggleWorkspaceSwitcher = (event: MouseEvent) => {
}
const refreshBalance = () => {
if (isActiveSubscription.value) {
void fetchBalance()
}
void fetchBalance()
}
defineExpose({ refreshBalance })

View File

@@ -3,6 +3,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useBillingContext } from './useBillingContext'
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
teamWorkspacesEnabled: true
}
})
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
const isInPersonalWorkspace = { value: true }
const activeWorkspace = { value: { id: 'personal-123', type: 'personal' } }
@@ -10,6 +18,7 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
useTeamWorkspaceStore: () => ({
isInPersonalWorkspace: isInPersonalWorkspace.value,
activeWorkspace: activeWorkspace.value,
updateActiveWorkspace: vi.fn(),
_setPersonalWorkspace: (value: boolean) => {
isInPersonalWorkspace.value = value
activeWorkspace.value = value
@@ -80,7 +89,10 @@ vi.mock('@/platform/workspace/api/workspaceApi', () => ({
currency: 'usd'
}),
subscribe: vi.fn().mockResolvedValue({ status: 'subscribed' }),
previewSubscribe: vi.fn().mockResolvedValue({ allowed: true })
previewSubscribe: vi.fn().mockResolvedValue({ allowed: true }),
getPaymentPortalUrl: vi
.fn()
.mockResolvedValue({ url: 'https://example.com/billing' })
}
}))
@@ -90,35 +102,37 @@ describe('useBillingContext', () => {
vi.clearAllMocks()
})
it('returns legacy type for personal workspace', () => {
it('returns workspace type for personal workspace when feature flag enabled', () => {
const { type } = useBillingContext()
expect(type.value).toBe('legacy')
expect(type.value).toBe('workspace')
})
it('provides subscription info from legacy billing', () => {
const { subscription } = useBillingContext()
it('provides subscription info from workspace billing', async () => {
const { subscription, initialize } = useBillingContext()
await initialize()
expect(subscription.value).toEqual({
isActive: true,
tier: 'PRO',
duration: 'MONTHLY',
planSlug: null,
renewalDate: 'Jan 1, 2025',
renewalDate: null,
endDate: null,
isCancelled: false,
hasFunds: true
})
})
it('provides balance info from legacy billing', () => {
const { balance } = useBillingContext()
it('provides balance info from workspace billing', async () => {
const { balance, initialize } = useBillingContext()
await initialize()
expect(balance.value).toEqual({
amountMicros: 5000000,
amountMicros: 10000000,
currency: 'usd',
effectiveBalanceMicros: 5000000,
prepaidBalanceMicros: 0,
cloudCreditBalanceMicros: 0
effectiveBalanceMicros: undefined,
prepaidBalanceMicros: undefined,
cloudCreditBalanceMicros: undefined
})
})
@@ -139,7 +153,7 @@ describe('useBillingContext', () => {
it('exposes subscribe action', async () => {
const { subscribe } = useBillingContext()
await expect(subscribe('pro-monthly')).resolves.toBeUndefined()
await expect(subscribe('pro-monthly')).resolves.toBeDefined()
})
it('exposes manageSubscription action', async () => {

View File

@@ -83,13 +83,11 @@ function useBillingContextInternal(): BillingContext {
/**
* Determines which billing type to use:
* - If team workspaces feature is disabled: always use legacy (/customers)
* - If team workspaces feature is enabled:
* - Personal workspace: use legacy (/customers)
* - Team workspace: use workspace (/billing)
* - If team workspaces feature is enabled: always use workspace (/billing)
*/
const type = computed<BillingType>(() => {
if (!flags.teamWorkspacesEnabled) return 'legacy'
return store.isInPersonalWorkspace ? 'legacy' : 'workspace'
return 'workspace'
})
const activeContext = computed(() =>
@@ -121,7 +119,7 @@ function useBillingContextInternal(): BillingContext {
watch(
subscription,
(sub) => {
if (!sub || store.isInPersonalWorkspace) return
if (!sub) return
store.updateActiveWorkspace({
isSubscribed: sub.isActive && !sub.isCancelled,

View File

@@ -0,0 +1,132 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockDialogService = vi.hoisted(() => ({
showLayoutDialog: vi.fn()
}))
const mockDialogStore = vi.hoisted(() => ({
closeDialog: vi.fn()
}))
const mockNewUserService = vi.hoisted(() => ({
isNewUser: vi.fn()
}))
const mockTelemetry = vi.hoisted(() => ({
trackTemplateLibraryOpened: vi.fn()
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => mockDialogService
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => mockDialogStore
}))
vi.mock('@/services/useNewUserService', () => ({
useNewUserService: () => mockNewUserService
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => mockTelemetry
}))
vi.mock(
'@/components/custom/widget/WorkflowTemplateSelectorDialog.vue',
() => ({
default: { name: 'MockWorkflowTemplateSelectorDialog' }
})
)
import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelectorDialog'
describe('useWorkflowTemplateSelectorDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('show', () => {
it('defaults to "all" category for non-new users', () => {
mockNewUserService.isNewUser.mockReturnValue(false)
const dialog = useWorkflowTemplateSelectorDialog()
dialog.show()
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledWith(
expect.objectContaining({
props: expect.objectContaining({
initialCategory: 'all'
})
})
)
})
it('defaults to "basics-getting-started" category for new users', () => {
mockNewUserService.isNewUser.mockReturnValue(true)
const dialog = useWorkflowTemplateSelectorDialog()
dialog.show()
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledWith(
expect.objectContaining({
props: expect.objectContaining({
initialCategory: 'basics-getting-started'
})
})
)
})
it('defaults to "all" when new user status is undetermined', () => {
mockNewUserService.isNewUser.mockReturnValue(null)
const dialog = useWorkflowTemplateSelectorDialog()
dialog.show()
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledWith(
expect.objectContaining({
props: expect.objectContaining({
initialCategory: 'all'
})
})
)
})
it('uses explicit initialCategory when provided', () => {
mockNewUserService.isNewUser.mockReturnValue(true)
const dialog = useWorkflowTemplateSelectorDialog()
dialog.show('command', { initialCategory: 'custom-category' })
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledWith(
expect.objectContaining({
props: expect.objectContaining({
initialCategory: 'custom-category'
})
})
)
})
it('tracks telemetry with source', () => {
mockNewUserService.isNewUser.mockReturnValue(false)
const dialog = useWorkflowTemplateSelectorDialog()
dialog.show('sidebar')
expect(mockTelemetry.trackTemplateLibraryOpened).toHaveBeenCalledWith({
source: 'sidebar'
})
})
})
describe('hide', () => {
it('closes the dialog', () => {
const dialog = useWorkflowTemplateSelectorDialog()
dialog.hide()
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'global-workflow-template-selector'
})
})
})
})

View File

@@ -1,26 +1,37 @@
import WorkflowTemplateSelectorDialog from '@/components/custom/widget/WorkflowTemplateSelectorDialog.vue'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useNewUserService } from '@/services/useNewUserService'
import { useDialogStore } from '@/stores/dialogStore'
const DIALOG_KEY = 'global-workflow-template-selector'
const GETTING_STARTED_CATEGORY_ID = 'basics-getting-started'
export const useWorkflowTemplateSelectorDialog = () => {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const newUserService = useNewUserService()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show(source: 'sidebar' | 'menu' | 'command' = 'command') {
function show(
source: 'sidebar' | 'menu' | 'command' = 'command',
options?: { initialCategory?: string }
) {
useTelemetry()?.trackTemplateLibraryOpened({ source })
const initialCategory =
options?.initialCategory ??
(newUserService.isNewUser() ? GETTING_STARTED_CATEGORY_ID : 'all')
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: WorkflowTemplateSelectorDialog,
props: {
onClose: hide
onClose: hide,
initialCategory
},
dialogComponentProps: {
pt: {

View File

@@ -23,11 +23,9 @@ export function getComfyApiBaseUrl(): string {
return BUILD_TIME_API_BASE_URL
}
return configValueOrDefault(
remoteConfig.value,
'comfy_api_base_url',
BUILD_TIME_API_BASE_URL
)
// In cloud mode, proxy all comfy-api requests through the cloud server
// instead of calling api.comfy.org directly
return ''
}
export function getComfyPlatformBaseUrl(): string {

View File

@@ -2,7 +2,6 @@ import { defineAsyncComponent } from 'vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
const DIALOG_KEY = 'subscription-required'
@@ -10,15 +9,13 @@ export const useSubscriptionDialog = () => {
const { flags } = useFeatureFlags()
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show() {
const useWorkspaceVariant =
flags.teamWorkspacesEnabled && !workspaceStore.isInPersonalWorkspace
const useWorkspaceVariant = flags.teamWorkspacesEnabled
const component = useWorkspaceVariant
? defineAsyncComponent(

View File

@@ -4,11 +4,10 @@ import { ref } from 'vue'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
const API_BASE_URL = 'https://api.comfy.org'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
const registryApiClient = axios.create({
baseURL: API_BASE_URL,
baseURL: getComfyApiBaseUrl(),
headers: {
'Content-Type': 'application/json'
},

View File

@@ -340,10 +340,8 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
email: string,
password: string
): Promise<UserCredential> => {
const result = await executeAuthAction(
(authInstance) =>
signInWithEmailAndPassword(authInstance, email, password),
{ createCustomer: true }
const result = await executeAuthAction((authInstance) =>
signInWithEmailAndPassword(authInstance, email, password)
)
if (isCloud) {
@@ -361,10 +359,8 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
email: string,
password: string
): Promise<UserCredential> => {
const result = await executeAuthAction(
(authInstance) =>
createUserWithEmailAndPassword(authInstance, email, password),
{ createCustomer: true }
const result = await executeAuthAction((authInstance) =>
createUserWithEmailAndPassword(authInstance, email, password)
)
if (isCloud) {
@@ -379,9 +375,8 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
}
const loginWithGoogle = async (): Promise<UserCredential> => {
const result = await executeAuthAction(
(authInstance) => signInWithPopup(authInstance, googleProvider),
{ createCustomer: true }
const result = await executeAuthAction((authInstance) =>
signInWithPopup(authInstance, googleProvider)
)
if (isCloud) {
@@ -398,9 +393,8 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
}
const loginWithGithub = async (): Promise<UserCredential> => {
const result = await executeAuthAction(
(authInstance) => signInWithPopup(authInstance, githubProvider),
{ createCustomer: true }
const result = await executeAuthAction((authInstance) =>
signInWithPopup(authInstance, githubProvider)
)
if (isCloud) {