Compare commits

...

26 Commits

Author SHA1 Message Date
Austin Mroz
2c523ba2c8 Move label loading into autogrow code 2026-03-28 13:47:52 -07:00
Austin Mroz
cd33b16061 Simplify rename logic 2026-03-28 13:36:46 -07:00
Arthur R Longbottom
dbd2767225 fix: use live getter for combo values, works in both LG and Vue mode
Replace onDrawForeground polling with Object.defineProperty getter on
comboWidget.options.values. Every read gets fresh labels from connected
inputs, ensuring the dropdown stays in sync after renames in Vue mode.
2026-03-27 19:48:12 -05:00
Arthur R Longbottom
2e944a6be4 fix: use local label override for immediate Vue reactivity on rename 2026-03-27 19:43:19 -05:00
Arthur R Longbottom
09a481588d fix: prevent double finishRename from enter+blur race 2026-03-27 19:37:14 -05:00
Arthur R Longbottom
1855d4cc03 fix: move tooltip to connection dot only to prevent hover CLS 2026-03-27 19:34:40 -05:00
Arthur R Longbottom
b5007573b1 feat: add slot-text-highlight semantic color for hover feedback
Add --node-component-slot-text-highlight token: black on light theme,
white on dark theme. Use it for input slot label hover instead of
hardcoded text-white.
2026-03-27 19:32:58 -05:00
Arthur R Longbottom
2a065c5093 fix: use white hover color, remove transition to prevent CLS 2026-03-27 19:30:55 -05:00
Arthur R Longbottom
58b795b419 fix: remove cursor-text from slot label, keep crosshair 2026-03-27 19:29:28 -05:00
Arthur R Longbottom
8448821ee1 revert: remove cursor-default, keep parent crosshair 2026-03-27 19:28:23 -05:00
Arthur R Longbottom
081a55007a fix: use default cursor on slot label area 2026-03-27 19:26:15 -05:00
Arthur R Longbottom
3f684bd95c fix: Vue slot rename — match font size, prevent error sound, fix reactivity
- Add .prevent on enter/escape keydown to suppress error beep
- Use text-[length:inherit] leading-[inherit] to match span font size
- Add right-click on connection dot to start rename
- Defer isRenaming=false to nextTick so label propagates before re-render
2026-03-27 19:22:20 -05:00
Arthur R Longbottom
8b32d696f0 fix: restore renamed slot labels after configure
Autogrow recreates inputs fresh during configure, losing custom labels.
Hook onConfigure to re-apply labels from serialized workflow data.
2026-03-27 19:15:00 -05:00
Arthur R Longbottom
08799e8163 fix: only show connected inputs in branch combo, fix serialization
- Filter combo values to only show inputs with active connections
- Serialize the relative index within connected inputs (not absolute
  input index) so the backend's autogrow dict lookup succeeds
- Update draw-frame check to track both connections and labels
2026-03-27 19:13:43 -05:00
Arthur R Longbottom
61760be9ff feat: double-click to rename input slots in Vue nodes mode
- Add inline rename on double-click with input field
- Hover effect transitions slot text to lighter gray
- Fires node:slot-label:changed trigger for reactivity
- Respects nameLocked flag
2026-03-27 19:09:46 -05:00
Arthur R Longbottom
9f70912899 fix: add getSlotMenuOptions to BranchNode for slot rename
The default slot context menu skips "Rename Slot" for inputs that have
a widget property set. Override getSlotMenuOptions on BranchNode to
always show the rename option for input slots.
2026-03-27 19:06:36 -05:00
Arthur R Longbottom
bd596997b8 fix: branch node combo reactivity and LG slot rename trigger
- Replace values() function with shallowReactive array for Vue tracking
- Add onDrawForeground check to detect label changes and refresh combo
- Fire node:slot-label:changed from LG "Rename Slot" context menu
  (previously mutated slot.label directly without triggering)
2026-03-27 18:56:38 -05:00
Austin Mroz
98d0cca188 Fix slice and serialization
lazy doesn't seem to track correctly on the backend. Needs further
investigation
2026-03-27 18:48:04 -05:00
Austin Mroz
a125261b8a POC branch node 2026-03-27 18:48:03 -05:00
pythongosssss
dabfc6521e test: Add test to prevent regression of workflow corruption during graph loading (#10623)
## Summary

Adds regression test for
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9531

## Changes

- **What**:  
- registers extension that triggers checkState during
afterConfigureGraph (at this point the workflow data and active graph
are not in sync), previously causing it to overwrite the workflow data
- switches between tabs
- ensures they are not corrupted

Line 35 can be uncommented to cause the test to fail by clearing the
isLoadingGraph flag

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10623-test-Add-test-to-prevent-regression-of-workflow-corruption-during-graph-loading-3306d73d3650815fbf02ef23dfdcddce)
by [Unito](https://www.unito.io)
2026-03-27 11:54:07 -07:00
Jin Yi
2f9431c6dd fix: stop Escape key propagation in Select components (#10397) 2026-03-27 18:50:04 +09:00
Christian Byrne
62979e3818 refactor: rename firebaseAuthStore to authStore with shared test fixtures (#10483)
## Summary

Rename `useFirebaseAuthStore` → `useAuthStore` and
`FirebaseAuthStoreError` → `AuthStoreError`. Introduce shared mock
factory (`authStoreMock.ts`) to replace 16 independent bespoke mocks.

## Changes

- **What**: Mechanical rename of store, composable, class, and store ID
(`firebaseAuth` → `auth`). Created
`src/stores/__tests__/authStoreMock.ts` — a shared mock factory with
reactive controls, used by all consuming test files. Migrated all 16
test files from ad-hoc mocks to the shared factory.
- **Files**: 62 files changed (rename propagation + new test infra)

## Review Focus

- Mock factory API design in `authStoreMock.ts` — covers all store
properties with reactive `controls` for per-test customization
- Self-test in `authStoreMock.test.ts` validates computed reactivity

Fixes #8219

## Stack

This is PR 1/5 in a stacked refactoring series:
1. **→ This PR**: Rename + shared test fixtures
2. #10484: Extract auth-routing from workspaceApi
3. #10485: Auth token priority tests
4. #10486: Decompose MembersPanelContent
5. #10487: Consolidate SubscriptionTier type

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-27 00:31:11 -07:00
Kelly Yang
6e249f2e05 fix: prevent canvas zoom when scrolling image history dropdown (#10550)
## Summary
 
Fix #10549 where using the mouse wheel over the image history dropdown
(e.g., in "Load Image" nodes) would trigger canvas zooming instead of
scrolling the list.

## Changes
Added `data-capture-wheel="true" ` to the root container. This attribute
is used by the `TransformPane` to identify elements that should consume
wheel events.

## Screenshots
 
after


https://github.com/user-attachments/assets/8935a1ca-9053-4ef1-9ab8-237f43eabb35

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10550-fix-prevent-canvas-zoom-when-scrolling-image-history-dropdown-32f6d73d365081c4ad09f763481ef8c2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-27 06:43:24 +00:00
Jin Yi
a1c46d7086 fix: replace hardcoded font-size 10px/11px with text-2xs Tailwind token (#10604)
## Summary

Replace all hardcoded `text-[10px]`, `text-[11px]`, and `font-size:
10px` with a new `text-2xs` Tailwind theme token (0.625rem / 10px).

## Changes

- **What**: Add `--text-2xs` custom theme token to design system CSS and
replace 14 hardcoded font-size occurrences across 12 files with
`text-2xs`.

## Review Focus

Consistent use of design tokens instead of arbitrary values for small
font sizes.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10604-fix-replace-hardcoded-font-size-10px-11px-with-text-2xs-Tailwind-token-3306d73d365081dfa1ebdc278e0a20b7)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-26 23:35:05 -07:00
Benjamin Lu
dd89b74ca5 fix: wait for settings before cloud desktop promo (#10526)
this fixes two issues, setting store race did not await load, and it
only cleared shown on clear not on show

## Summary

Wait for settings to load before deciding whether to show the one-time
macOS desktop cloud promo so the persisted dismissal state is respected
on launch.

## Changes

- **What**: Await `settingStore.load()` before checking
`Comfy.Desktop.CloudNotificationShown`, keep the promo gated to macOS
desktop, and persist the shown flag before awaiting dialog close.
- **Dependencies**: None

## Review Focus

- Launch-time settings race for `Comfy.Desktop.CloudNotificationShown`
- One-time modal behavior if the app closes before the dialog is
dismissed
- Regression coverage in `src/App.test.ts`

## Screenshots (if applicable)

- N/A

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10526-fix-wait-for-settings-before-cloud-desktop-promo-32e6d73d365081939fc3ca5b4346b873)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-26 22:31:31 -07:00
Christian Byrne
e809d74192 perf: disable Sentry event target wrapping to reduce DOM event overhead (#10472)
## Summary

Disable Sentry `browserApiErrorsIntegration` event target wrapping for
cloud builds to eliminate 231.7ms of `sentryWrapped` overhead during
canvas interaction.

## Changes

- **What**: Configure `browserApiErrorsIntegration({ eventTarget: false
})` in the cloud Sentry init path. This prevents Sentry from wrapping
every `addEventListener` callback in try/catch, which was the #1 hot
function during multi-cluster panning (100 profiling samples). Error
capturing still works via `window.onerror` and `unhandledrejection`.

## Review Focus

- Confirm that disabling event target wrapping is acceptable for cloud
error monitoring — Sentry still captures unhandled errors, just not
errors thrown inside individual event handler callbacks.
- Non-cloud builds already had `integrations: []` /
`defaultIntegrations: false`, so no change there.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10472-perf-disable-Sentry-event-target-wrapping-to-reduce-DOM-event-overhead-32d6d73d365081cdb455e47aee34dcc6)
by [Unito](https://www.unito.io)
2026-03-26 22:06:47 -07:00
87 changed files with 991 additions and 369 deletions

View File

@@ -0,0 +1,66 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
import type { WorkspaceStore } from '../types/globals'
test.describe(
'Change Tracker - isLoadingGraph guard',
{ tag: '@workflow' },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.setupWorkflowsDirectory({})
})
test('Prevents checkState from corrupting workflow state during tab switch', async ({
comfyPage
}) => {
// Tab 0: default workflow (7 nodes)
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
// Save tab 0 so it has a unique name for tab switching
await comfyPage.menu.topbar.saveWorkflow('workflow-a')
// Register an extension that forces checkState during graph loading.
// This simulates the bug scenario where a user clicks during graph loading
// which triggers a checkState call on the wrong graph, corrupting the activeState.
await comfyPage.page.evaluate(() => {
window.app!.registerExtension({
name: 'TestCheckStateDuringLoad',
afterConfigureGraph() {
const workflow = (window.app!.extensionManager as WorkspaceStore)
.workflow.activeWorkflow
if (!workflow) throw new Error('No workflow found')
// Bypass the guard to reproduce the corruption bug:
// ; (workflow.changeTracker.constructor as unknown as { isLoadingGraph: boolean }).isLoadingGraph = false
// Simulate the user clicking during graph loading
workflow.changeTracker.checkState()
}
})
})
// Create tab 1: blank workflow (0 nodes)
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
// Switch back to tab 0 (workflow-a).
const tab0 = comfyPage.menu.topbar.getWorkflowTab('workflow-a')
await tab0.click()
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
// switch to blank tab and back to verify no corruption
const tab1 = comfyPage.menu.topbar.getWorkflowTab('Unsaved Workflow')
await tab1.click()
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
// switch again and verify no corruption
await tab0.click()
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
})
}
)

View File

@@ -25,6 +25,9 @@
@theme {
--shadow-interface: var(--interface-panel-box-shadow);
--text-2xs: 0.625rem;
--text-2xs--line-height: calc(1 / 0.625);
--text-xxs: 0.625rem;
--text-xxs--line-height: calc(1 / 0.625);
@@ -246,6 +249,7 @@
--node-component-slot-dot-outline-opacity: 5%;
--node-component-slot-dot-outline: var(--color-black);
--node-component-slot-text: var(--color-ash-800);
--node-component-slot-text-highlight: var(--color-black);
--node-component-surface-highlight: var(--color-ash-500);
--node-component-surface-hovered: var(--color-smoke-200);
--node-component-surface-selected: var(--color-charcoal-200);
@@ -388,6 +392,7 @@
--node-component-slot-dot-outline-opacity: 10%;
--node-component-slot-dot-outline: var(--color-white);
--node-component-slot-text: var(--color-slate-200);
--node-component-slot-text-highlight: var(--color-white);
--node-component-surface-highlight: var(--color-slate-100);
--node-component-surface-hovered: var(--color-charcoal-600);
--node-component-surface-selected: var(--color-charcoal-200);
@@ -534,6 +539,9 @@
)
);
--color-node-component-slot-text: var(--node-component-slot-text);
--color-node-component-slot-text-highlight: var(
--node-component-slot-text-highlight
);
--color-node-component-surface-highlight: var(
--node-component-surface-highlight
);

View File

@@ -7,17 +7,15 @@
<script setup lang="ts">
import { captureException } from '@sentry/vue'
import BlockUI from 'primevue/blockui'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { computed, onMounted, watch } from 'vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI } from '@/utils/envUtil'
import { parsePreloadError } from '@/utils/preloadErrorUtil'
import { useDialogService } from '@/services/dialogService'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
const workspaceStore = useWorkspaceStore()
@@ -129,26 +127,5 @@ onMounted(() => {
// Initialize conflict detection in background
// This runs async and doesn't block UI setup
void conflictDetection.initializeConflictDetection()
// Show cloud notification for macOS desktop users (one-time)
if (isDesktop && electronAPI()?.getPlatform() === 'darwin') {
const settingStore = useSettingStore()
if (!settingStore.get('Comfy.Desktop.CloudNotificationShown')) {
const dialogService = useDialogService()
cloudNotificationTimer = setTimeout(async () => {
try {
await dialogService.showCloudNotification()
} catch (e) {
console.warn('[CloudNotification] Failed to show', e)
}
await settingStore.set('Comfy.Desktop.CloudNotificationShown', true)
}, 2000)
}
}
})
let cloudNotificationTimer: ReturnType<typeof setTimeout> | undefined
onUnmounted(() => {
if (cloudNotificationTimer) clearTimeout(cloudNotificationTimer)
})
</script>

View File

@@ -71,8 +71,8 @@ vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
})
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
currentUser: null,
loading: false
}))

View File

@@ -21,7 +21,7 @@
/>
<span class="p-breadcrumb-item-label px-2">{{ item.label }}</span>
<Tag v-if="item.isBlueprint" value="Blueprint" severity="primary" />
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
<i v-if="isActive" class="pi pi-angle-down text-2xs"></i>
</div>
<Menu
v-if="isActive || isRoot"

View File

@@ -32,8 +32,8 @@ const mockBalance = vi.hoisted(() => ({
const mockIsFetchingBalance = vi.hoisted(() => ({ value: false }))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
balance: mockBalance.value,
isFetchingBalance: mockIsFetchingBalance.value
}))

View File

@@ -30,14 +30,14 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
const { textClass, showCreditsOnly } = defineProps<{
textClass?: string
showCreditsOnly?: boolean
}>()
const authStore = useFirebaseAuthStore()
const authStore = useAuthStore()
const balanceLoading = computed(() => authStore.isFetchingBalance)
const { t, locale } = useI18n()

View File

@@ -147,7 +147,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import {
configValueOrDefault,
@@ -167,7 +167,7 @@ const { onSuccess } = defineProps<{
}>()
const { t } = useI18n()
const authActions = useFirebaseAuthActions()
const authActions = useAuthActions()
const isSecureContext = window.isSecureContext
const isSignIn = ref(true)
const showApiKeyForm = ref(false)

View File

@@ -156,7 +156,7 @@ import { useI18n } from 'vue-i18n'
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
@@ -171,7 +171,7 @@ const { isInsufficientCredits = false } = defineProps<{
}>()
const { t } = useI18n()
const authActions = useFirebaseAuthActions()
const authActions = useAuthActions()
const dialogStore = useDialogStore()
const settingsDialog = useSettingsDialog()
const telemetry = useTelemetry()

View File

@@ -21,10 +21,10 @@ import { ref } from 'vue'
import PasswordFields from '@/components/dialog/content/signin/PasswordFields.vue'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { updatePasswordSchema } from '@/schemas/signInSchema'
const authActions = useFirebaseAuthActions()
const authActions = useAuthActions()
const loading = ref(false)
const { onSuccess } = defineProps<{

View File

@@ -116,12 +116,12 @@ import UserCredit from '@/components/common/UserCredit.vue'
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
interface CreditHistoryItemData {
@@ -133,8 +133,8 @@ interface CreditHistoryItemData {
const { buildDocsUrl, docsPaths } = useExternalLink()
const dialogService = useDialogService()
const authStore = useFirebaseAuthStore()
const authActions = useFirebaseAuthActions()
const authStore = useAuthStore()
const authActions = useAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const { isActiveSubscription } = useBillingContext()

View File

@@ -18,8 +18,8 @@ import ApiKeyForm from './ApiKeyForm.vue'
const mockStoreApiKey = vi.fn()
const mockLoading = vi.fn(() => false)
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
loading: mockLoading()
}))
}))

View File

@@ -100,9 +100,9 @@ import {
} from '@/platform/remoteConfig/remoteConfig'
import { apiKeySchema } from '@/schemas/signInSchema'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
const authStore = useFirebaseAuthStore()
const authStore = useAuthStore()
const apiKeyStore = useApiKeyAuthStore()
const loading = computed(() => authStore.loading)
const comfyPlatformBaseUrl = computed(() =>

View File

@@ -35,15 +35,15 @@ vi.mock('firebase/auth', () => ({
// Mock the auth composables and stores
const mockSendPasswordReset = vi.fn()
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: vi.fn(() => ({
sendPasswordReset: mockSendPasswordReset
}))
}))
let mockLoading = false
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
get loading() {
return mockLoading
}

View File

@@ -88,14 +88,14 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { signInSchema } from '@/schemas/signInSchema'
import type { SignInData } from '@/schemas/signInSchema'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import { cn } from '@/utils/tailwindUtil'
const authStore = useFirebaseAuthStore()
const firebaseAuthActions = useFirebaseAuthActions()
const authStore = useAuthStore()
const authActions = useAuthActions()
const loading = computed(() => authStore.loading)
const toast = useToast()
@@ -127,6 +127,6 @@ const handleForgotPassword = async (
document.getElementById(emailInputId)?.focus?.()
return
}
await firebaseAuthActions.sendPasswordReset(email)
await authActions.sendPasswordReset(email)
}
</script>

View File

@@ -54,12 +54,12 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { signUpSchema } from '@/schemas/signInSchema'
import type { SignUpData } from '@/schemas/signInSchema'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import PasswordFields from './PasswordFields.vue'
const { t } = useI18n()
const authStore = useFirebaseAuthStore()
const authStore = useAuthStore()
const loading = computed(() => authStore.loading)
const emit = defineEmits<{

View File

@@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import MultiSelect from './MultiSelect.vue'
@@ -21,26 +21,59 @@ const i18n = createI18n({
}
})
describe('MultiSelect', () => {
function createWrapper() {
return mount(MultiSelect, {
attachTo: document.body,
global: {
plugins: [i18n]
},
props: {
modelValue: [],
label: 'Category',
options: [
{ name: 'One', value: 'one' },
{ name: 'Two', value: 'two' }
]
const options = [
{ name: 'Option A', value: 'a' },
{ name: 'Option B', value: 'b' },
{ name: 'Option C', value: 'c' }
]
function mountInParent(
multiSelectProps: Record<string, unknown> = {},
modelValue: { name: string; value: string }[] = []
) {
const parentEscapeCount = { value: 0 }
const Parent = {
template:
'<div @keydown.escape="onEsc"><MultiSelect v-model="sel" :options="options" v-bind="extraProps" /></div>',
components: { MultiSelect },
setup() {
return {
sel: ref(modelValue),
options,
extraProps: multiSelectProps,
onEsc: () => {
parentEscapeCount.value++
}
}
})
}
}
const wrapper = mount(Parent, {
attachTo: document.body,
global: { plugins: [i18n] }
})
return { wrapper, parentEscapeCount }
}
function dispatchEscape(element: Element) {
element.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
bubbles: true
})
)
}
function findContentElement(): HTMLElement | null {
return document.querySelector('[data-dismissable-layer]')
}
describe('MultiSelect', () => {
it('keeps open-state border styling available while the dropdown is open', async () => {
const wrapper = createWrapper()
const { wrapper } = mountInParent()
const trigger = wrapper.get('button[aria-haspopup="listbox"]')
@@ -57,4 +90,65 @@ describe('MultiSelect', () => {
wrapper.unmount()
})
describe('Escape key propagation', () => {
it('stops Escape from propagating to parent when popover is open', async () => {
const { wrapper, parentEscapeCount } = mountInParent()
const trigger = wrapper.find('button[aria-haspopup="listbox"]')
await trigger.trigger('click')
await nextTick()
const content = findContentElement()
expect(content).not.toBeNull()
dispatchEscape(content!)
await nextTick()
expect(parentEscapeCount.value).toBe(0)
wrapper.unmount()
})
it('closes the popover when Escape is pressed', async () => {
const { wrapper } = mountInParent()
const trigger = wrapper.find('button[aria-haspopup="listbox"]')
await trigger.trigger('click')
await nextTick()
expect(trigger.attributes('data-state')).toBe('open')
const content = findContentElement()
dispatchEscape(content!)
await nextTick()
expect(trigger.attributes('data-state')).toBe('closed')
wrapper.unmount()
})
})
describe('selected count badge', () => {
it('shows selected count when items are selected', () => {
const { wrapper } = mountInParent({}, [
{ name: 'Option A', value: 'a' },
{ name: 'Option B', value: 'b' }
])
expect(wrapper.text()).toContain('2')
wrapper.unmount()
})
it('does not show count badge when no items are selected', () => {
const { wrapper } = mountInParent()
const multiSelect = wrapper.findComponent(MultiSelect)
const spans = multiSelect.findAll('span')
const countBadge = spans.find((s) => /^\d+$/.test(s.text().trim()))
expect(countBadge).toBeUndefined()
wrapper.unmount()
})
})
})

View File

@@ -1,6 +1,7 @@
<template>
<ComboboxRoot
v-model="selectedItems"
v-model:open="isOpen"
multiple
by="value"
:disabled
@@ -13,17 +14,10 @@
:aria-label="label || t('g.multiSelectDropdown')"
:class="
cn(
'relative inline-flex cursor-pointer items-center select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg bg-secondary-background text-base-foreground',
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid border-transparent',
selectedCount > 0
? 'border-base-foreground'
: 'focus-visible:border-node-component-border data-[state=open]:border-node-component-border',
disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
selectTriggerVariants({
size,
border: selectedCount > 0 ? 'active' : 'none'
})
)
"
>
@@ -45,9 +39,7 @@
{{ selectedCount }}
</span>
</div>
<div
class="flex shrink-0 cursor-pointer items-center justify-center px-3"
>
<div :class="selectDropdownClass">
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</div>
</ComboboxTrigger>
@@ -59,19 +51,8 @@
:side-offset="8"
align="start"
:style="popoverStyle"
:class="
cn(
'z-3000 overflow-hidden',
'rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default',
'shadow-md',
'data-[state=closed]:animate-out data-[state=open]:animate-in',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2'
)
"
:class="selectContentClass"
@keydown="onContentKeydown"
@focus-outside="preventFocusDismiss"
>
<div
@@ -132,13 +113,7 @@
v-for="opt in filteredOptions"
:key="opt.value"
:value="opt"
:class="
cn(
'group flex h-10 shrink-0 cursor-pointer items-center gap-2 rounded-lg px-2 outline-none',
'hover:bg-secondary-background-hover',
'data-highlighted:bg-secondary-background-selected data-highlighted:hover:bg-secondary-background-selected'
)
"
:class="cn('group', selectItemVariants({ layout: 'multi' }))"
>
<div
class="flex size-4 shrink-0 items-center justify-center rounded-sm transition-all duration-200 group-data-[state=checked]:bg-primary-background group-data-[state=unchecked]:bg-secondary-background [&>span]:flex"
@@ -151,7 +126,7 @@
</div>
<span>{{ opt.name }}</span>
</ComboboxItem>
<ComboboxEmpty class="px-3 pb-4 text-sm text-muted-foreground">
<ComboboxEmpty :class="selectEmptyMessageClass">
{{ $t('g.noResultsFound') }}
</ComboboxEmpty>
</ComboboxViewport>
@@ -176,13 +151,21 @@ import {
ComboboxTrigger,
ComboboxViewport
} from 'reka-ui'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
import {
selectContentClass,
selectDropdownClass,
selectEmptyMessageClass,
selectItemVariants,
selectTriggerVariants,
stopEscapeToDocument
} from './select.variants'
import type { SelectOption } from './types'
defineOptions({
@@ -232,8 +215,16 @@ const selectedItems = defineModel<SelectOption[]>({
const searchQuery = defineModel<string>('searchQuery', { default: '' })
const { t } = useI18n()
const isOpen = ref(false)
const selectedCount = computed(() => selectedItems.value.length)
function onContentKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
stopEscapeToDocument(event)
isOpen.value = false
}
}
function preventFocusDismiss(event: FocusOutsideEvent) {
event.preventDefault()
}

View File

@@ -0,0 +1,116 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import SingleSelect from './SingleSelect.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
singleSelectDropdown: 'Single-select dropdown'
}
}
}
})
const options = [
{ name: 'Option A', value: 'a' },
{ name: 'Option B', value: 'b' },
{ name: 'Option C', value: 'c' }
]
function dispatchEscape(element: Element) {
element.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
bubbles: true
})
)
}
function findContentElement(): HTMLElement | null {
return document.querySelector('[data-dismissable-layer]')
}
function mountInParent(modelValue?: string) {
const parentEscapeCount = { value: 0 }
const Parent = {
template:
'<div @keydown.escape="onEsc"><SingleSelect v-model="sel" :options="options" label="Pick" /></div>',
components: { SingleSelect },
setup() {
return {
sel: ref(modelValue),
options,
onEsc: () => {
parentEscapeCount.value++
}
}
}
}
const wrapper = mount(Parent, {
attachTo: document.body,
global: { plugins: [i18n] }
})
return { wrapper, parentEscapeCount }
}
async function openSelect(triggerEl: HTMLElement) {
if (!triggerEl.hasPointerCapture) {
triggerEl.hasPointerCapture = () => false
triggerEl.releasePointerCapture = () => {}
}
triggerEl.dispatchEvent(
new PointerEvent('pointerdown', {
button: 0,
pointerType: 'mouse',
bubbles: true
})
)
await nextTick()
}
describe('SingleSelect', () => {
describe('Escape key propagation', () => {
it('stops Escape from propagating to parent when popover is open', async () => {
const { wrapper, parentEscapeCount } = mountInParent()
const trigger = wrapper.find('button[role="combobox"]')
await openSelect(trigger.element as HTMLElement)
const content = findContentElement()
expect(content).not.toBeNull()
dispatchEscape(content!)
await nextTick()
expect(parentEscapeCount.value).toBe(0)
wrapper.unmount()
})
it('closes the popover when Escape is pressed', async () => {
const { wrapper } = mountInParent()
const trigger = wrapper.find('button[role="combobox"]')
await openSelect(trigger.element as HTMLElement)
expect(trigger.attributes('data-state')).toBe('open')
const content = findContentElement()
dispatchEscape(content!)
await nextTick()
expect(trigger.attributes('data-state')).toBe('closed')
wrapper.unmount()
})
})
})

View File

@@ -1,23 +1,15 @@
<template>
<SelectRoot v-model="selectedItem" :disabled>
<SelectRoot v-model="selectedItem" v-model:open="isOpen" :disabled>
<SelectTrigger
v-bind="$attrs"
:aria-label="label || t('g.singleSelectDropdown')"
:aria-busy="loading || undefined"
:aria-invalid="invalid || undefined"
:class="
cn(
'relative inline-flex cursor-pointer items-center select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg',
'bg-secondary-background text-base-foreground',
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid',
invalid ? 'border-destructive-background' : 'border-transparent',
'focus:border-node-component-border focus:outline-none',
'disabled:cursor-default disabled:opacity-30 disabled:hover:bg-secondary-background'
)
selectTriggerVariants({
size,
border: invalid ? 'invalid' : 'none'
})
"
>
<div
@@ -35,9 +27,7 @@
<slot v-else name="icon" />
<SelectValue :placeholder="label" class="truncate" />
</div>
<div
class="flex shrink-0 cursor-pointer items-center justify-center px-3"
>
<div :class="selectDropdownClass">
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</div>
</SelectTrigger>
@@ -48,20 +38,8 @@
:side-offset="8"
align="start"
:style="optionStyle"
:class="
cn(
'z-3000 overflow-hidden',
'rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default',
'shadow-md',
'min-w-(--reka-select-trigger-width)',
'data-[state=closed]:animate-out data-[state=open]:animate-in',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2'
)
"
:class="cn(selectContentClass, 'min-w-(--reka-select-trigger-width)')"
@keydown="onContentKeydown"
>
<SelectViewport
:style="{ maxHeight: `min(${listMaxHeight}, 50vh)` }"
@@ -71,16 +49,7 @@
v-for="opt in options"
:key="opt.value"
:value="opt.value"
:class="
cn(
'relative flex w-full cursor-pointer items-center justify-between select-none',
'gap-3 rounded-sm px-2 py-3 text-sm outline-none',
'hover:bg-secondary-background-hover',
'focus:bg-secondary-background-hover',
'data-[state=checked]:bg-secondary-background-selected',
'data-[state=checked]:hover:bg-secondary-background-selected'
)
"
:class="selectItemVariants({ layout: 'single' })"
>
<SelectItemText class="truncate">
{{ opt.name }}
@@ -112,11 +81,19 @@ import {
SelectValue,
SelectViewport
} from 'reka-ui'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
import {
selectContentClass,
selectDropdownClass,
selectItemVariants,
selectTriggerVariants,
stopEscapeToDocument
} from './select.variants'
import type { SelectOption } from './types'
defineOptions({
@@ -155,6 +132,14 @@ const {
const selectedItem = defineModel<string | undefined>({ required: true })
const { t } = useI18n()
const isOpen = ref(false)
function onContentKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
stopEscapeToDocument(event)
isOpen.value = false
}
}
const optionStyle = usePopoverSizing({
minWidth: popoverMinWidth,

View File

@@ -0,0 +1,50 @@
import { cva } from 'cva'
export const selectTriggerVariants = cva({
base: 'relative inline-flex cursor-pointer items-center select-none rounded-lg bg-secondary-background text-base-foreground outline-none transition-all duration-200 ease-in-out hover:bg-secondary-background-hover border-[2.5px] border-solid disabled:cursor-default disabled:opacity-30 disabled:hover:bg-secondary-background',
variants: {
size: {
md: 'h-8',
lg: 'h-10'
},
border: {
none: 'border-transparent focus-visible:border-node-component-border data-[state=open]:border-node-component-border',
active: 'border-base-foreground',
invalid: 'border-destructive-background'
}
},
defaultVariants: {
size: 'lg',
border: 'none'
}
})
export const selectItemVariants = cva({
base: 'flex cursor-pointer items-center px-2 outline-none hover:bg-secondary-background-hover',
variants: {
layout: {
multi:
'h-10 shrink-0 gap-2 rounded-lg data-highlighted:bg-secondary-background-selected data-highlighted:hover:bg-secondary-background-selected',
single:
'relative w-full justify-between gap-3 rounded-sm py-3 text-sm select-none focus:bg-secondary-background-hover data-[state=checked]:bg-secondary-background-selected data-[state=checked]:hover:bg-secondary-background-selected'
}
},
defaultVariants: {
layout: 'multi'
}
})
export const selectContentClass =
'z-3000 overflow-hidden rounded-lg p-2 bg-base-background text-base-foreground border border-solid border-border-default shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2'
export const selectDropdownClass =
'flex shrink-0 cursor-pointer items-center justify-center px-3'
export const selectEmptyMessageClass = 'px-3 pb-4 text-sm text-muted-foreground'
export function stopEscapeToDocument(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.stopPropagation()
event.stopImmediatePropagation()
}
}

View File

@@ -32,7 +32,7 @@
<!-- Description -->
<p
v-if="nodeDef.description"
class="m-0 text-[11px] leading-normal font-normal text-muted-foreground"
class="m-0 text-2xs/normal font-normal text-muted-foreground"
>
{{ nodeDef.description }}
</p>

View File

@@ -20,7 +20,7 @@
</div>
<div
v-if="showDescription"
class="flex items-center gap-1 text-[11px] text-muted-foreground"
class="flex items-center gap-1 text-2xs text-muted-foreground"
>
<span
v-if="

View File

@@ -31,7 +31,7 @@
v-if="shouldShowBadge"
:class="
cn(
'sidebar-icon-badge absolute min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] leading-[14px] font-medium text-base-foreground',
'sidebar-icon-badge absolute min-w-[16px] rounded-full bg-primary-background py-0.25 text-2xs leading-[14px] font-medium text-base-foreground',
badgeClass || '-top-1 -right-1'
)
"
@@ -42,7 +42,7 @@
</slot>
<span
v-if="label && !isSmall"
class="side-bar-button-label text-center text-[10px]"
class="side-bar-button-label text-center text-2xs"
>{{ st(label, label) }}</span
>
</div>

View File

@@ -85,7 +85,7 @@ const modelDef = props.modelDef
display: inline-block;
text-align: center;
margin: 5px;
font-size: 10px;
font-size: var(--text-2xs);
}
.model_preview_prefix {
font-weight: 700;

View File

@@ -61,10 +61,10 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
}))
}))
// Mock the useFirebaseAuthActions composable
// Mock the useAuthActions composable
const mockLogout = vi.fn()
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: vi.fn(() => ({
fetchBalance: vi.fn().mockResolvedValue(undefined),
logout: mockLogout
}))
@@ -77,7 +77,7 @@ vi.mock('@/services/dialogService', () => ({
}))
}))
// Mock the firebaseAuthStore with hoisted state for per-test manipulation
// Mock the authStore with hoisted state for per-test manipulation
const mockAuthStoreState = vi.hoisted(() => ({
balance: {
amount_micros: 100_000,
@@ -91,8 +91,8 @@ const mockAuthStoreState = vi.hoisted(() => ({
isFetchingBalance: false
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
getAuthHeader: vi
.fn()
.mockResolvedValue({ Authorization: 'Bearer mock-token' }),

View File

@@ -159,7 +159,7 @@ import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
@@ -168,7 +168,7 @@ import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
const emit = defineEmits<{
close: []
@@ -178,8 +178,8 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useFirebaseAuthActions()
const authStore = useFirebaseAuthStore()
const authActions = useAuthActions()
const authStore = useAuthStore()
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const {

View File

@@ -11,8 +11,8 @@ import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { BillingPortalTargetTier } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import type { BillingPortalTargetTier } from '@/stores/authStore'
import { usdToMicros } from '@/utils/formatUtil'
/**
@@ -20,8 +20,8 @@ import { usdToMicros } from '@/utils/formatUtil'
* All actions are wrapped with error handling.
* @returns {Object} - Object containing all Firebase Auth actions
*/
export const useFirebaseAuthActions = () => {
const authStore = useFirebaseAuthStore()
export const useAuthActions = () => {
const authStore = useAuthStore()
const toastStore = useToastStore()
const { wrapWithErrorHandlingAsync, toastErrorHandler } = useErrorHandling()

View File

@@ -3,11 +3,11 @@ import { computed, watch } from 'vue'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useCommandStore } from '@/stores/commandStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import type { AuthUserInfo } from '@/types/authTypes'
export const useCurrentUser = () => {
const authStore = useFirebaseAuthStore()
const authStore = useAuthStore()
const commandStore = useCommandStore()
const apiKeyStore = useApiKeyAuthStore()

View File

@@ -70,8 +70,8 @@ vi.mock(
})
)
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
balance: { amount_micros: 5000000 },
fetchBalance: vi.fn().mockResolvedValue({ amount_micros: 5000000 })
})

View File

@@ -5,7 +5,7 @@ import type {
PreviewSubscribeResponse,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import type {
BalanceInfo,
@@ -33,7 +33,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
showSubscriptionDialog: legacyShowSubscriptionDialog
} = useSubscription()
const firebaseAuthStore = useFirebaseAuthStore()
const authStore = useAuthStore()
const isInitialized = ref(false)
const isLoading = ref(false)
@@ -55,12 +55,12 @@ export function useLegacyBilling(): BillingState & BillingActions {
renewalDate: formattedRenewalDate.value || null,
endDate: formattedEndDate.value || null,
isCancelled: isCancelled.value,
hasFunds: (firebaseAuthStore.balance?.amount_micros ?? 0) > 0
hasFunds: (authStore.balance?.amount_micros ?? 0) > 0
}
})
const balance = computed<BalanceInfo | null>(() => {
const legacyBalance = firebaseAuthStore.balance
const legacyBalance = authStore.balance
if (!legacyBalance) return null
return {
@@ -118,7 +118,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
isLoading.value = true
error.value = null
try {
await firebaseAuthStore.fetchBalance()
await authStore.fetchBalance()
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to fetch balance'

View File

@@ -56,8 +56,8 @@ vi.mock('@/scripts/api', () => ({
vi.mock('@/platform/settings/settingStore')
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({}))
}))
vi.mock('@/composables/auth/useFirebaseAuth', () => ({
@@ -123,8 +123,8 @@ vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: vi.fn(() => ({}))
}))
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({}))
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: vi.fn(() => ({}))
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({

View File

@@ -1,5 +1,5 @@
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations'
import { useExternalLink } from '@/composables/useExternalLink'
@@ -78,7 +78,7 @@ export function useCoreCommands(): ComfyCommand[] {
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const colorPaletteStore = useColorPaletteStore()
const firebaseAuthActions = useFirebaseAuthActions()
const authActions = useAuthActions()
const toastStore = useToastStore()
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
@@ -996,7 +996,7 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Sign Out',
versionAdded: '1.18.1',
function: async () => {
await firebaseAuthActions.logout()
await authActions.logout()
}
},
{

View File

@@ -572,6 +572,20 @@ function withComfyAutogrow(node: LGraphNode): asserts node is AutogrowNode {
}
}
)
// Restore renamed labels after configure (autogrow recreates inputs fresh)
node.onConfigure = useChainCallback(
node.onConfigure,
(data: { inputs?: Array<{ label?: string; name: string }> }) => {
if (!data?.inputs) return
for (const serializedInput of data.inputs) {
if (!serializedInput.label) continue
const match = node.inputs.find(
(inp) => inp.name === serializedInput.name
)
if (match) match.label = serializedInput.label
}
}
)
}
function applyAutogrow(node: LGraphNode, inputSpecV2: InputSpecV2) {
withComfyAutogrow(node)

View File

@@ -1,4 +1,4 @@
import { shallowReactive } from 'vue'
import { computed, ref, shallowReactive, watch } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
@@ -124,6 +124,42 @@ function onCustomComboCreated(this: LGraphNode) {
addOption(this)
}
const renameTrigger = ref(0)
function onBranchSelectorCreated(this: LGraphNode) {
this.applyToGraph = applyToGraph
//FIXME: watchers probably leak since we're not in a vue component
const connectionsTrigger = ref(0)
this.widgets?.pop()
const labels = computed(() => {
void renameTrigger.value
void connectionsTrigger.value
return this.inputs.slice(0, -2).map((inp) => inp.label)
})
const values = () => labels.value
const node = this
const comboWidget = this.addWidget('combo', 'branch', '', () => {}, {
values
})
watch([renameTrigger, connectionsTrigger], () => {
if (app.configuringGraph || labels.value.includes(`${comboWidget.value}`))
return
comboWidget.value = labels.value[0] ?? ''
comboWidget.callback?.(comboWidget.value)
})
comboWidget.serializeValue = () =>
node.inputs.slice(0, -1).findIndex((inp) => inp.label === comboWidget.value)
// Refresh on connection changes (add/remove inputs)
this.onConnectionsChange = useChainCallback(
this.onConnectionsChange,
() => connectionsTrigger.value++
)
}
function onCustomIntCreated(this: LGraphNode) {
const valueWidget = this.widgets?.[0]
if (!valueWidget) return
@@ -227,6 +263,11 @@ app.registerExtension({
nodeType.prototype.onNodeCreated,
onCustomComboCreated
)
else if (nodeData?.name === 'BranchNode')
nodeType.prototype.onNodeCreated = useChainCallback(
nodeType.prototype.onNodeCreated,
onBranchSelectorCreated
)
else if (nodeData?.name === 'PrimitiveInt')
nodeType.prototype.onNodeCreated = useChainCallback(
nodeType.prototype.onNodeCreated,
@@ -237,5 +278,11 @@ app.registerExtension({
nodeType.prototype.onNodeCreated,
onCustomFloatCreated
)
},
init() {
app.graph.onTrigger = useChainCallback(app.graph.onTrigger, (e) => {
if (e.type !== 'node:slot-label:changed') return
renameTrigger.value++
})
}
})

View File

@@ -97,6 +97,7 @@ import {
LinkDirection,
LinkMarkerShape,
LinkRenderType,
NodeSlotType,
RenderShape,
TitleMode
} from './types/globalEnums'
@@ -8803,6 +8804,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (input?.value) {
if (slot_info) {
slot_info.label = input.value
node.graph?.trigger('node:slot-label:changed', {
nodeId: node.id,
slotType: info.input ? NodeSlotType.INPUT : NodeSlotType.OUTPUT
})
}
setDirty()
}

View File

@@ -67,7 +67,14 @@ Sentry.init({
replaysOnErrorSampleRate: 0,
// Only set these for non-cloud builds
...(isCloud
? {}
? {
integrations: [
// Disable event target wrapping to reduce overhead on high-frequency
// DOM events (pointermove, mousemove, wheel). Sentry still captures
// errors via window.onerror and unhandledrejection.
Sentry.browserApiErrorsIntegration({ eventTarget: false })
]
}
: {
integrations: [],
autoSessionTracking: false,

View File

@@ -1,7 +1,7 @@
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
/**
* Session cookie management for cloud authentication.
@@ -21,7 +21,7 @@ export const useSessionCookie = () => {
const { flags } = useFeatureFlags()
try {
const authStore = useFirebaseAuthStore()
const authStore = useAuthStore()
let authHeader: Record<string, string>

View File

@@ -0,0 +1,135 @@
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import DesktopCloudNotificationController from './DesktopCloudNotificationController.vue'
const settingState = {
shown: false
}
const settingStore = {
load: vi.fn<() => Promise<void>>(),
get: vi.fn((key: string) =>
key === 'Comfy.Desktop.CloudNotificationShown'
? settingState.shown
: undefined
),
set: vi.fn(async (_key: string, value: boolean) => {
settingState.shown = value
})
}
const dialogService = {
showCloudNotification: vi.fn<() => Promise<void>>()
}
const electron = {
getPlatform: vi.fn(() => 'darwin')
}
vi.mock('@/platform/distribution/types', () => ({
isDesktop: true
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => settingStore
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => dialogService
}))
vi.mock('@/utils/envUtil', () => ({
electronAPI: () => electron
}))
function createDeferred() {
let resolve!: () => void
const promise = new Promise<void>((res) => {
resolve = res
})
return { promise, resolve }
}
describe('DesktopCloudNotificationController', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
settingState.shown = false
electron.getPlatform.mockReturnValue('darwin')
settingStore.load.mockResolvedValue(undefined)
settingStore.set.mockImplementation(
async (_key: string, value: boolean) => {
settingState.shown = value
}
)
dialogService.showCloudNotification.mockResolvedValue(undefined)
})
afterEach(() => {
vi.useRealTimers()
})
it('waits for settings to load before deciding whether to show the notification', async () => {
const loadSettings = createDeferred()
settingStore.load.mockImplementation(() => loadSettings.promise)
const wrapper = mount(DesktopCloudNotificationController)
await nextTick()
settingState.shown = true
loadSettings.resolve()
await flushPromises()
await vi.advanceTimersByTimeAsync(2000)
expect(dialogService.showCloudNotification).not.toHaveBeenCalled()
wrapper.unmount()
})
it('does not schedule or show the notification after unmounting before settings load resolves', async () => {
const loadSettings = createDeferred()
settingStore.load.mockImplementation(() => loadSettings.promise)
const wrapper = mount(DesktopCloudNotificationController)
await nextTick()
wrapper.unmount()
loadSettings.resolve()
await flushPromises()
await vi.advanceTimersByTimeAsync(2000)
expect(settingStore.set).not.toHaveBeenCalled()
expect(dialogService.showCloudNotification).not.toHaveBeenCalled()
})
it('marks the notification as shown before awaiting dialog close', async () => {
const dialogOpen = createDeferred()
dialogService.showCloudNotification.mockImplementation(
() => dialogOpen.promise
)
const wrapper = mount(DesktopCloudNotificationController)
await flushPromises()
await vi.advanceTimersByTimeAsync(2000)
expect(settingStore.set).toHaveBeenCalledWith(
'Comfy.Desktop.CloudNotificationShown',
true
)
expect(settingStore.set.mock.invocationCallOrder[0]).toBeLessThan(
dialogService.showCloudNotification.mock.invocationCallOrder[0]
)
dialogOpen.resolve()
await flushPromises()
wrapper.unmount()
})
})

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useDialogService } from '@/services/dialogService'
import { electronAPI } from '@/utils/envUtil'
const settingStore = useSettingStore()
const dialogService = useDialogService()
let isDisposed = false
let cloudNotificationTimer: ReturnType<typeof setTimeout> | undefined
async function scheduleCloudNotification() {
if (!isDesktop || electronAPI()?.getPlatform() !== 'darwin') return
try {
await settingStore.load()
} catch (error) {
console.warn('[CloudNotification] Failed to load settings', error)
return
}
if (isDisposed) return
if (settingStore.get('Comfy.Desktop.CloudNotificationShown')) return
cloudNotificationTimer = setTimeout(async () => {
if (isDisposed) return
try {
await settingStore.set('Comfy.Desktop.CloudNotificationShown', true)
if (isDisposed) return
await dialogService.showCloudNotification()
} catch (error) {
console.warn('[CloudNotification] Failed to show', error)
await settingStore
.set('Comfy.Desktop.CloudNotificationShown', false)
.catch((resetError) => {
console.warn(
'[CloudNotification] Failed to reset shown state',
resetError
)
})
}
}, 2000)
}
onMounted(() => {
void scheduleCloudNotification()
})
onUnmounted(() => {
isDisposed = true
if (cloudNotificationTimer) clearTimeout(cloudNotificationTimer)
})
</script>

View File

@@ -74,7 +74,7 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
interface Props {
errorMessage?: string
@@ -83,7 +83,7 @@ interface Props {
defineProps<Props>()
const router = useRouter()
const { logout } = useFirebaseAuthActions()
const { logout } = useAuthActions()
const showTechnicalDetails = ref(false)
const handleRestart = async () => {

View File

@@ -76,11 +76,11 @@ import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
const { t } = useI18n()
const router = useRouter()
const authActions = useFirebaseAuthActions()
const authActions = useAuthActions()
const email = ref('')
const loading = ref(false)

View File

@@ -110,7 +110,7 @@ import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import CloudSignInForm from '@/platform/cloud/onboarding/components/CloudSignInForm.vue'
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
@@ -120,7 +120,7 @@ import type { SignInData } from '@/schemas/signInSchema'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const authActions = useFirebaseAuthActions()
const authActions = useAuthActions()
const isSecureContext = globalThis.isSecureContext
const authError = ref('')
const toastStore = useToastStore()

View File

@@ -42,7 +42,7 @@
</Button>
<span
v-if="isFreeTierEnabled"
class="absolute -top-2.5 -right-2.5 rounded-full bg-yellow-400 px-2 py-0.5 text-[10px] font-bold whitespace-nowrap text-gray-900"
class="absolute -top-2.5 -right-2.5 rounded-full bg-yellow-400 px-2 py-0.5 text-2xs font-bold whitespace-nowrap text-gray-900"
>
{{ t('auth.login.freeTierBadge') }}
</span>
@@ -133,7 +133,7 @@ import { useRoute, useRouter } from 'vue-router'
import SignUpForm from '@/components/dialog/content/signin/SignUpForm.vue'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
import { isCloud } from '@/platform/distribution/types'
@@ -145,7 +145,7 @@ import { isInChina } from '@/utils/networkUtil'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const authActions = useFirebaseAuthActions()
const authActions = useAuthActions()
const isSecureContext = globalThis.isSecureContext
const authError = ref('')
const userIsInChina = ref(false)

View File

@@ -25,8 +25,8 @@ const authActionMocks = vi.hoisted(() => ({
accessBillingPortal: vi.fn()
}))
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: () => authActionMocks
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: () => authActionMocks
}))
vi.mock('@/composables/useErrorHandling', () => ({

View File

@@ -6,7 +6,7 @@ import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil'
@@ -16,7 +16,7 @@ import type { BillingCycle } from '../subscription/utils/subscriptionTierRank'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
const { reportError, accessBillingPortal } = useAuthActions()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { isActiveSubscription, isInitialized, initialize } = useBillingContext()

View File

@@ -89,9 +89,9 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { signInSchema } from '@/schemas/signInSchema'
import type { SignInData } from '@/schemas/signInSchema'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
const authStore = useFirebaseAuthStore()
const authStore = useAuthStore()
const loading = computed(() => authStore.loading)
const { t } = useI18n()

View File

@@ -31,8 +31,8 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
})
}))
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: () => ({
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: () => ({
accessBillingPortal: mockAccessBillingPortal,
reportError: mockReportError
})
@@ -56,13 +56,13 @@ vi.mock('@/composables/useErrorHandling', () => ({
})
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () =>
vi.mock('@/stores/authStore', () => ({
useAuthStore: () =>
reactive({
getAuthHeader: mockGetAuthHeader,
userId: computed(() => mockUserId.value)
}),
FirebaseAuthStoreError: class extends Error {}
AuthStoreError: class extends Error {}
}))
vi.mock('@/platform/telemetry', () => ({

View File

@@ -30,7 +30,7 @@
<span>{{ option.label }}</span>
<div
v-if="option.value === 'yearly'"
class="flex items-center rounded-full bg-primary-background px-1 py-0.5 text-[11px] font-bold text-white"
class="flex items-center rounded-full bg-primary-background px-1 py-0.5 text-2xs font-bold text-white"
>
-20%
</div>
@@ -58,7 +58,7 @@
</span>
<div
v-if="tier.isPopular"
class="flex h-5 items-center rounded-full bg-base-foreground px-1.5 text-[11px] font-bold tracking-tight text-base-background uppercase"
class="flex h-5 items-center rounded-full bg-base-foreground px-1.5 text-2xs font-bold tracking-tight text-base-background uppercase"
>
{{ t('subscription.mostPopular') }}
</div>
@@ -262,7 +262,7 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import {
@@ -279,7 +279,7 @@ import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscript
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import type { components } from '@/types/comfyRegistryTypes'
type SubscriptionTier = components['schemas']['SubscriptionTier']
@@ -365,8 +365,8 @@ const {
isYearlySubscription
} = useSubscription()
const telemetry = useTelemetry()
const { userId } = storeToRefs(useFirebaseAuthStore())
const { accessBillingPortal, reportError } = useFirebaseAuthActions()
const { userId } = storeToRefs(useAuthStore())
const { accessBillingPortal, reportError } = useAuthActions()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const isLoading = ref(false)

View File

@@ -82,8 +82,8 @@ vi.mock(
})
)
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: vi.fn(() => ({
authActions: vi.fn(() => ({
accessBillingPortal: vi.fn()
}))

View File

@@ -211,7 +211,7 @@ import { computed, onBeforeUnmount, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
@@ -227,7 +227,7 @@ import type { TierBenefit } from '@/platform/cloud/subscription/utils/tierBenefi
import { getCommonTierBenefits } from '@/platform/cloud/subscription/utils/tierBenefits'
import { cn } from '@/utils/tailwindUtil'
const authActions = useFirebaseAuthActions()
const authActions = useAuthActions()
const { t, n } = useI18n()
const {

View File

@@ -65,8 +65,8 @@ vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => mockTelemetry)
}))
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: vi.fn(() => ({
reportError: mockReportError,
accessBillingPortal: mockAccessBillingPortal
}))
@@ -106,14 +106,14 @@ vi.mock('@/services/dialogService', () => ({
}))
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
getAuthHeader: mockGetAuthHeader,
get userId() {
return mockUserId.value
}
})),
FirebaseAuthStoreError: class extends Error {}
AuthStoreError: class extends Error {}
}))
// Mock fetch

View File

@@ -2,7 +2,7 @@ import { computed, ref, watch } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
@@ -10,10 +10,7 @@ import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import {
FirebaseAuthStoreError,
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
import { useDialogService } from '@/services/dialogService'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import type { operations } from '@/types/comfyRegistryTypes'
@@ -37,11 +34,11 @@ function useSubscriptionInternal() {
return subscriptionStatus.value?.is_active ?? false
})
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
const { reportError, accessBillingPortal } = useAuthActions()
const { showSubscriptionRequiredDialog } = useDialogService()
const firebaseAuthStore = useFirebaseAuthStore()
const { getAuthHeader } = firebaseAuthStore
const authStore = useAuthStore()
const { getAuthHeader } = authStore
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { isLoggedIn } = useCurrentUser()
@@ -194,7 +191,7 @@ function useSubscriptionInternal() {
async function fetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const response = await fetch(
@@ -209,7 +206,7 @@ function useSubscriptionInternal() {
if (!response.ok) {
const errorData = await response.json()
throw new FirebaseAuthStoreError(
throw new AuthStoreError(
t('toastMessages.failedToFetchSubscription', {
error: errorData.message
})
@@ -248,9 +245,7 @@ function useSubscriptionInternal() {
async (): Promise<CloudSubscriptionCheckoutResponse> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(
t('toastMessages.userNotAuthenticated')
)
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const checkoutAttribution = await getCheckoutAttributionForCloud()
@@ -268,7 +263,7 @@ function useSubscriptionInternal() {
if (!response.ok) {
const errorData = await response.json()
throw new FirebaseAuthStoreError(
throw new AuthStoreError(
t('toastMessages.failedToInitiateSubscription', {
error: errorData.message
})

View File

@@ -8,8 +8,8 @@ const mockFetchStatus = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
const mockExecute = vi.fn()
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: () => ({
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: () => ({
fetchBalance: mockFetchBalance
})
}))

View File

@@ -1,7 +1,7 @@
import { onMounted, ref } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
@@ -12,7 +12,7 @@ import { useCommandStore } from '@/stores/commandStore'
*/
export function useSubscriptionActions() {
const dialogService = useDialogService()
const authActions = useFirebaseAuthActions()
const authActions = useAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const { fetchStatus } = useBillingContext()

View File

@@ -36,14 +36,14 @@ vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => mockTelemetry)
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() =>
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() =>
reactive({
getAuthHeader: mockGetAuthHeader,
userId: computed(() => mockUserId.value)
})
),
FirebaseAuthStoreError: class extends Error {}
AuthStoreError: class extends Error {}
}))
vi.mock('@/platform/distribution/types', () => ({

View File

@@ -4,10 +4,7 @@ import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import {
FirebaseAuthStoreError,
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from './subscriptionTierRank'
@@ -51,13 +48,13 @@ export async function performSubscriptionCheckout(
): Promise<void> {
if (!isCloud) return
const firebaseAuthStore = useFirebaseAuthStore()
const { userId } = storeToRefs(firebaseAuthStore)
const authStore = useAuthStore()
const { userId } = storeToRefs(authStore)
const telemetry = useTelemetry()
const authHeader = await firebaseAuthStore.getAuthHeader()
const authHeader = await authStore.getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle)
@@ -97,7 +94,7 @@ export async function performSubscriptionCheckout(
}
}
throw new FirebaseAuthStoreError(
throw new AuthStoreError(
t('toastMessages.failedToInitiateSubscription', {
error: errorMessage
})

View File

@@ -90,7 +90,7 @@ import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserM
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import NavItem from '@/components/widget/nav/NavItem.vue'
import NavTitle from '@/components/widget/nav/NavTitle.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
@@ -129,7 +129,7 @@ const {
getSearchResults
} = useSettingSearch()
const authActions = useFirebaseAuthActions()
const authActions = useAuthActions()
const navRef = ref<HTMLElement | null>(null)
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)

View File

@@ -6,7 +6,7 @@ type MockApiKeyUser = {
email?: string
} | null
type MockFirebaseUser = {
type MockAuthUser = {
uid: string
email?: string | null
} | null
@@ -14,19 +14,19 @@ type MockFirebaseUser = {
const {
mockCaptureCheckoutAttributionFromSearch,
mockUseApiKeyAuthStore,
mockUseFirebaseAuthStore,
mockUseAuthStore,
mockApiKeyAuthStore,
mockFirebaseAuthStore
mockAuthStore
} = vi.hoisted(() => ({
mockCaptureCheckoutAttributionFromSearch: vi.fn(),
mockUseApiKeyAuthStore: vi.fn(),
mockUseFirebaseAuthStore: vi.fn(),
mockUseAuthStore: vi.fn(),
mockApiKeyAuthStore: {
isAuthenticated: false,
currentUser: null as MockApiKeyUser
},
mockFirebaseAuthStore: {
currentUser: null as MockFirebaseUser
mockAuthStore: {
currentUser: null as MockAuthUser
}
}))
@@ -38,8 +38,8 @@ vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: mockUseApiKeyAuthStore
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: mockUseFirebaseAuthStore
vi.mock('@/stores/authStore', () => ({
useAuthStore: mockUseAuthStore
}))
import { ImpactTelemetryProvider } from './ImpactTelemetryProvider'
@@ -64,14 +64,14 @@ describe('ImpactTelemetryProvider', () => {
beforeEach(() => {
mockCaptureCheckoutAttributionFromSearch.mockReset()
mockUseApiKeyAuthStore.mockReset()
mockUseFirebaseAuthStore.mockReset()
mockUseAuthStore.mockReset()
mockApiKeyAuthStore.isAuthenticated = false
mockApiKeyAuthStore.currentUser = null
mockFirebaseAuthStore.currentUser = null
mockAuthStore.currentUser = null
vi.restoreAllMocks()
vi.unstubAllGlobals()
mockUseApiKeyAuthStore.mockReturnValue(mockApiKeyAuthStore)
mockUseFirebaseAuthStore.mockReturnValue(mockFirebaseAuthStore)
mockUseAuthStore.mockReturnValue(mockAuthStore)
const queueFn: NonNullable<Window['ire']> = (...args: unknown[]) => {
;(queueFn.a ??= []).push(args)
@@ -93,7 +93,7 @@ describe('ImpactTelemetryProvider', () => {
})
it('captures attribution and invokes identify with hashed email', async () => {
mockFirebaseAuthStore.currentUser = {
mockAuthStore.currentUser = {
uid: 'user-123',
email: ' User@Example.com '
}
@@ -153,7 +153,7 @@ describe('ImpactTelemetryProvider', () => {
})
it('invokes identify on each page view even with identical identity payloads', async () => {
mockFirebaseAuthStore.currentUser = {
mockAuthStore.currentUser = {
uid: 'user-123',
email: 'user@example.com'
}
@@ -189,7 +189,7 @@ describe('ImpactTelemetryProvider', () => {
id: 'api-key-user-123',
email: 'apikey@example.com'
}
mockFirebaseAuthStore.currentUser = {
mockAuthStore.currentUser = {
uid: 'firebase-user-123',
email: 'firebase@example.com'
}
@@ -228,7 +228,7 @@ describe('ImpactTelemetryProvider', () => {
id: 'api-key-user-123',
email: 'apikey@example.com'
}
mockFirebaseAuthStore.currentUser = null
mockAuthStore.currentUser = null
vi.stubGlobal('crypto', {
subtle: {
digest: vi.fn(

View File

@@ -1,6 +1,6 @@
import { captureCheckoutAttributionFromSearch } from '@/platform/telemetry/utils/checkoutAttribution'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import type { PageViewMetadata, TelemetryProvider } from '../../types'
@@ -17,7 +17,7 @@ export class ImpactTelemetryProvider implements TelemetryProvider {
private initialized = false
private stores: {
apiKeyAuthStore: ReturnType<typeof useApiKeyAuthStore>
firebaseAuthStore: ReturnType<typeof useFirebaseAuthStore>
authStore: ReturnType<typeof useAuthStore>
} | null = null
constructor() {
@@ -109,12 +109,11 @@ export class ImpactTelemetryProvider implements TelemetryProvider {
}
}
if (stores.firebaseAuthStore.currentUser) {
if (stores.authStore.currentUser) {
return {
customerId:
stores.firebaseAuthStore.currentUser.uid ?? EMPTY_CUSTOMER_VALUE,
customerId: stores.authStore.currentUser.uid ?? EMPTY_CUSTOMER_VALUE,
customerEmail:
stores.firebaseAuthStore.currentUser.email ?? EMPTY_CUSTOMER_VALUE
stores.authStore.currentUser.email ?? EMPTY_CUSTOMER_VALUE
}
}
@@ -135,7 +134,7 @@ export class ImpactTelemetryProvider implements TelemetryProvider {
private resolveAuthStores(): {
apiKeyAuthStore: ReturnType<typeof useApiKeyAuthStore>
firebaseAuthStore: ReturnType<typeof useFirebaseAuthStore>
authStore: ReturnType<typeof useAuthStore>
} | null {
if (this.stores) {
return this.stores
@@ -144,7 +143,7 @@ export class ImpactTelemetryProvider implements TelemetryProvider {
try {
const stores = {
apiKeyAuthStore: useApiKeyAuthStore(),
firebaseAuthStore: useFirebaseAuthStore()
authStore: useAuthStore()
}
this.stores = stores
return stores

View File

@@ -2,7 +2,7 @@ import axios from 'axios'
import { t } from '@/i18n'
import { api } from '@/scripts/api'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
export type WorkspaceType = 'personal' | 'team'
export type WorkspaceRole = 'owner' | 'member'
@@ -288,7 +288,7 @@ const workspaceApiClient = axios.create({
})
async function getAuthHeaderOrThrow() {
const authHeader = await useFirebaseAuthStore().getAuthHeader()
const authHeader = await useAuthStore().getAuthHeader()
if (!authHeader) {
throw new WorkspaceApiError(
t('toastMessages.userNotAuthenticated'),
@@ -300,7 +300,7 @@ async function getAuthHeaderOrThrow() {
}
async function getFirebaseHeaderOrThrow() {
const authHeader = await useFirebaseAuthStore().getFirebaseAuthHeader()
const authHeader = await useAuthStore().getFirebaseAuthHeader()
if (!authHeader) {
throw new WorkspaceApiError(
t('toastMessages.userNotAuthenticated'),

View File

@@ -8,8 +8,8 @@ import WorkspaceAuthGate from './WorkspaceAuthGate.vue'
const mockIsInitialized = ref(false)
const mockCurrentUser = ref<object | null>(null)
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
isInitialized: mockIsInitialized,
currentUser: mockCurrentUser
})

View File

@@ -27,7 +27,7 @@ import { isCloud } from '@/platform/distribution/types'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
const FIREBASE_INIT_TIMEOUT_MS = 16_000
const CONFIG_REFRESH_TIMEOUT_MS = 10_000
@@ -38,7 +38,7 @@ const subscriptionDialog = useSubscriptionDialog()
async function initialize(): Promise<void> {
if (!isCloud) return
const authStore = useFirebaseAuthStore()
const authStore = useAuthStore()
const { isInitialized, currentUser } = storeToRefs(authStore)
try {

View File

@@ -30,7 +30,7 @@
<span>{{ option.label }}</span>
<div
v-if="option.value === 'yearly'"
class="flex items-center rounded-full bg-primary-background px-1 py-0.5 text-[11px] font-bold text-white"
class="flex items-center rounded-full bg-primary-background px-1 py-0.5 text-2xs font-bold text-white"
>
-20%
</div>
@@ -58,7 +58,7 @@
</span>
<div
v-if="tier.isPopular"
class="flex h-5 items-center rounded-full bg-base-foreground px-1.5 text-[11px] font-bold tracking-tight text-base-background uppercase"
class="flex h-5 items-center rounded-full bg-base-foreground px-1.5 text-2xs font-bold tracking-tight text-base-background uppercase"
>
{{ t('subscription.mostPopular') }}
</div>

View File

@@ -48,7 +48,7 @@
</span>
<span
v-if="resolveTierLabel(workspace)"
class="rounded-full bg-base-foreground px-1 py-0.5 text-[10px] font-bold text-base-background uppercase"
class="rounded-full bg-base-foreground px-1 py-0.5 text-2xs font-bold text-base-background uppercase"
>
{{ resolveTierLabel(workspace) }}
</span>

View File

@@ -62,7 +62,7 @@
</span>
<span
v-if="tierLabels.get(workspace.id)"
class="shrink-0 rounded-full bg-base-foreground px-1 py-0.5 text-[10px] font-bold text-base-background uppercase"
class="shrink-0 rounded-full bg-base-foreground px-1 py-0.5 text-2xs font-bold text-base-background uppercase"
>
{{ tierLabels.get(workspace.id) }}
</span>

View File

@@ -153,7 +153,7 @@
</span>
<span
v-if="uiConfig.showRoleBadge"
class="rounded-full bg-base-foreground px-1 py-0.5 text-[10px] font-bold text-base-background uppercase"
class="rounded-full bg-base-foreground px-1 py-0.5 text-2xs font-bold text-base-background uppercase"
>
{{ $t('workspaceSwitcher.roleOwner') }}
</span>
@@ -202,7 +202,7 @@
</span>
<span
v-if="uiConfig.showRoleBadge"
class="rounded-full bg-base-foreground px-1 py-0.5 text-[10px] font-bold text-base-background uppercase"
class="rounded-full bg-base-foreground px-1 py-0.5 text-2xs font-bold text-base-background uppercase"
>
{{ getRoleBadgeLabel(member.role) }}
</span>

View File

@@ -10,8 +10,8 @@ import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
const mockGetIdToken = vi.fn()
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
getIdToken: mockGetIdToken
})
}))

View File

@@ -9,7 +9,7 @@ import {
WORKSPACE_STORAGE_KEYS
} from '@/platform/workspace/workspaceConstants'
import { api } from '@/scripts/api'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import type { AuthHeader } from '@/types/authTypes'
import type { WorkspaceWithRole } from '@/platform/workspace/workspaceTypes'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
@@ -181,8 +181,8 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
error.value = null
try {
const firebaseAuthStore = useFirebaseAuthStore()
const firebaseToken = await firebaseAuthStore.getIdToken()
const authStore = useAuthStore()
const firebaseToken = await authStore.getIdToken()
if (!firebaseToken) {
throw new WorkspaceAuthError(
t('workspaceAuth.errors.notAuthenticated'),

View File

@@ -2,10 +2,9 @@
<div v-if="renderError" class="node-error p-1 text-xs text-red-500"></div>
<div
v-else
v-tooltip.left="tooltipConfig"
:class="
cn(
'lg-slot lg-slot--input group m-0 flex items-center rounded-r-lg',
'lg-slot lg-slot--input group/slot m-0 flex items-center rounded-r-lg',
'cursor-crosshair',
dotOnly ? 'lg-slot--dot-only' : 'pr-6',
{
@@ -20,6 +19,7 @@
<!-- Connection Dot -->
<SlotConnectionDot
ref="connectionDotRef"
v-tooltip.left="tooltipConfig"
:class="
cn(
'w-3 -translate-x-1/2',
@@ -31,40 +31,51 @@
@click="onClick"
@dblclick="onDoubleClick"
@pointerdown="onPointerDown"
@contextmenu.stop.prevent="startRename"
/>
<!-- Slot Name -->
<div class="flex h-full min-w-0 items-center">
<input
v-if="isRenaming"
ref="renameInputRef"
v-model="renameValue"
class="m-0 w-full truncate border-none bg-transparent p-0 text-[length:inherit] leading-[inherit] text-node-component-slot-text outline-none"
@blur="finishRename"
@keydown.enter.prevent="finishRename"
@keydown.escape.prevent="cancelRename"
@click.stop
@pointerdown.stop
/>
<span
v-if="!props.dotOnly && !hasNoLabel"
v-else-if="!props.dotOnly && !hasNoLabel"
:class="
cn(
'truncate text-node-component-slot-text',
'truncate text-node-component-slot-text hover:text-node-component-slot-text-highlight',
hasError && 'font-medium text-error'
)
"
@dblclick.stop="startRename"
>
{{
slotData.label ||
slotData.localized_name ||
(slotData.name ?? `Input ${index}`)
}}
{{ displayLabel }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onErrorCaptured, ref, watchEffect } from 'vue'
import { computed, nextTick, onErrorCaptured, ref, watchEffect } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { app } from '@/scripts/app'
import { cn } from '@/utils/tailwindUtil'
import SlotConnectionDot from './SlotConnectionDot.vue'
@@ -83,6 +94,15 @@ interface InputSlotProps {
const props = defineProps<InputSlotProps>()
const labelOverride = ref<string | null>(null)
const displayLabel = computed(
() =>
labelOverride.value ||
props.slotData.label ||
props.slotData.localized_name ||
(props.slotData.name ?? `Input ${props.index}`)
)
const hasNoLabel = computed(
() =>
!props.slotData.label &&
@@ -142,4 +162,48 @@ const { onClick, onDoubleClick, onPointerDown } = useSlotLinkInteraction({
index: props.index,
type: 'input'
})
// ── Inline rename ─────────────────────────────────────────────
const isRenaming = ref(false)
const renameValue = ref('')
const renameInputRef = ref<HTMLInputElement | null>(null)
function startRename() {
if (props.slotData.nameLocked) return
renameValue.value = displayLabel.value
isRenaming.value = true
nextTick(() => {
renameInputRef.value?.select()
})
}
let renameCommitted = false
function finishRename() {
if (!isRenaming.value || renameCommitted) return
renameCommitted = true
const newLabel = renameValue.value.trim()
const node = app.canvas?.graph?.getNodeById(props.nodeId ?? '')
const slot = node?.inputs?.[props.index]
if (newLabel && newLabel !== displayLabel.value && slot) {
slot.label = newLabel
labelOverride.value = newLabel
node?.graph?.trigger('node:slot-label:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT
})
app.canvas?.setDirty(true, true)
}
nextTick(() => {
isRenaming.value = false
renameCommitted = false
})
}
function cancelRename() {
isRenaming.value = false
}
</script>

View File

@@ -110,4 +110,19 @@ describe('FormDropdownMenu', () => {
const virtualGrid = wrapper.findComponent({ name: 'VirtualGrid' })
expect(virtualGrid.props('maxColumns')).toBe(1)
})
it('has data-capture-wheel="true" on the root element', () => {
const wrapper = mount(FormDropdownMenu, {
props: defaultProps,
global: {
stubs: {
FormDropdownMenuFilter: true,
FormDropdownMenuActions: true,
VirtualGrid: true
}
}
})
expect(wrapper.attributes('data-capture-wheel')).toBe('true')
})
})

View File

@@ -98,6 +98,7 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
<template>
<div
class="flex h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline -outline-offset-1 outline-node-component-border"
data-capture-wheel="true"
>
<FormDropdownMenuFilter
v-if="filterOptions.length > 0"

View File

@@ -103,6 +103,7 @@ function toggleBaseModelSelection(item: FilterOption) {
<div class="text-secondary flex gap-2 px-4">
<FormSearchInput
v-model="searchQuery"
autofocus
:class="
cn(
actionButtonStyle,

View File

@@ -38,9 +38,9 @@ vi.mock('@/platform/distribution/types', () => ({
}
}))
vi.mock('@/stores/firebaseAuthStore', async () => {
vi.mock('@/stores/authStore', async () => {
return {
useFirebaseAuthStore: vi.fn(() => ({
useAuthStore: vi.fn(() => ({
getAuthHeader: vi.fn(() => Promise.resolve(mockCloudAuth.authHeader))
}))
}

View File

@@ -5,7 +5,7 @@ import type { IWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isCloud } from '@/platform/distribution/types'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
const MAX_RETRIES = 5
const TIMEOUT = 4096
@@ -23,7 +23,7 @@ interface CacheEntry<T> {
async function getAuthHeaders() {
if (isCloud) {
const authStore = useFirebaseAuthStore()
const authStore = useAuthStore()
const authHeader = await authStore.getAuthHeader()
return {
...(authHeader && { headers: authHeader })

View File

@@ -11,7 +11,7 @@ import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud, isDesktop } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import { useUserStore } from '@/stores/userStore'
import LayoutDefault from '@/views/layouts/LayoutDefault.vue'
@@ -140,7 +140,7 @@ if (isCloud) {
}
// Global authentication guard
router.beforeEach(async (to, _from, next) => {
const authStore = useFirebaseAuthStore()
const authStore = useAuthStore()
// Wait for Firebase auth to initialize
// Timeout after 16 seconds

View File

@@ -60,7 +60,7 @@ import type {
JobListItem
} from '@/platform/remote/comfyui/jobs/jobTypes'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { useAuthStore } from '@/stores/authStore'
import type { AuthHeader } from '@/types/authTypes'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import {
@@ -332,7 +332,7 @@ export class ComfyApi extends EventTarget {
/**
* Cache Firebase auth store composable function.
*/
private authStoreComposable?: typeof useFirebaseAuthStore
private authStoreComposable?: typeof useAuthStore
reportedUnknownMessageTypes = new Set<string>()
@@ -399,8 +399,8 @@ export class ComfyApi extends EventTarget {
private async getAuthStore() {
if (isCloud) {
if (!this.authStoreComposable) {
const module = await import('@/stores/firebaseAuthStore')
this.authStoreComposable = module.useFirebaseAuthStore
const module = await import('@/stores/authStore')
this.authStoreComposable = module.useAuthStore
}
return this.authStoreComposable()

View File

@@ -67,7 +67,7 @@ import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useExtensionStore } from '@/stores/extensionStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
@@ -1594,7 +1594,7 @@ export class ComfyApp {
executionErrorStore.clearAllErrors()
// Get auth token for backend nodes - uses workspace token if enabled, otherwise Firebase token
const comfyOrgAuthToken = await useFirebaseAuthStore().getAuthToken()
const comfyOrgAuthToken = await useAuthStore().getAuthToken()
const comfyOrgApiKey = useApiKeyAuthStore().getApiKey()
try {

View File

@@ -11,7 +11,7 @@ const mockAxiosInstance = vi.hoisted(() => ({
get: vi.fn()
}))
const mockFirebaseAuthStore = vi.hoisted(() => ({
const mockAuthStore = vi.hoisted(() => ({
getAuthHeader: vi.fn()
}))
@@ -27,8 +27,8 @@ vi.mock('axios', () => ({
}
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => mockFirebaseAuthStore)
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => mockAuthStore)
}))
vi.mock('@/i18n', () => ({
@@ -81,7 +81,7 @@ describe('useCustomerEventsService', () => {
vi.clearAllMocks()
// Setup default mocks
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(mockAuthHeaders)
mockAuthStore.getAuthHeader.mockResolvedValue(mockAuthHeaders)
mockI18n.d.mockImplementation((date, options) => {
// Mock i18n date formatting
if (options?.month === 'short') {
@@ -118,7 +118,7 @@ describe('useCustomerEventsService', () => {
limit: 10
})
expect(mockFirebaseAuthStore.getAuthHeader).toHaveBeenCalled()
expect(mockAuthStore.getAuthHeader).toHaveBeenCalled()
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/customers/events', {
params: { page: 1, limit: 10 },
headers: mockAuthHeaders
@@ -141,7 +141,7 @@ describe('useCustomerEventsService', () => {
})
it('should return null when auth headers are missing', async () => {
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(null)
mockAuthStore.getAuthHeader.mockResolvedValue(null)
const result = await service.getMyEvents()

View File

@@ -4,7 +4,7 @@ import { ref, watch } from 'vue'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { d } from '@/i18n'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
@@ -180,7 +180,7 @@ export const useCustomerEventsService = () => {
}
// Get auth headers
const authHeaders = await useFirebaseAuthStore().getAuthHeader()
const authHeaders = await useAuthStore().getAuthHeader()
if (!authHeaders) {
error.value = 'Authentication header is missing'
return null

View File

@@ -5,7 +5,7 @@ import { computed, ref, watch } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import type { ApiKeyAuthHeader } from '@/types/authTypes'
import type { operations } from '@/types/comfyRegistryTypes'
@@ -15,7 +15,7 @@ type ComfyApiUser =
const STORAGE_KEY = 'comfy_api_key'
export const useApiKeyAuthStore = defineStore('apiKeyAuth', () => {
const firebaseAuthStore = useFirebaseAuthStore()
const authStore = useAuthStore()
const apiKey = useLocalStorage<string | null>(STORAGE_KEY, null)
const toastStore = useToastStore()
const { wrapWithErrorHandlingAsync, toastErrorHandler } = useErrorHandling()
@@ -24,7 +24,7 @@ export const useApiKeyAuthStore = defineStore('apiKeyAuth', () => {
const isAuthenticated = computed(() => !!currentUser.value)
const initializeUserFromApiKey = async () => {
const createCustomerResponse = await firebaseAuthStore
const createCustomerResponse = await authStore
.createCustomer()
.catch((err) => {
console.error(err)

View File

@@ -7,7 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as vuefire from 'vuefire'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import { createTestingPinia } from '@pinia/testing'
// Hoisted mocks for dynamic imports
@@ -122,8 +122,8 @@ vi.mock('@/stores/apiKeyAuthStore', () => ({
})
}))
describe('useFirebaseAuthStore', () => {
let store: ReturnType<typeof useFirebaseAuthStore>
describe('useAuthStore', () => {
let store: ReturnType<typeof useAuthStore>
let authStateCallback: (user: User | null) => void
let idTokenCallback: (user: User | null) => void
@@ -182,7 +182,7 @@ describe('useFirebaseAuthStore', () => {
// Initialize Pinia
setActivePinia(createTestingPinia({ stubActions: false }))
store = useFirebaseAuthStore()
store = useAuthStore()
// Reset and set up getIdToken mock
mockUser.getIdToken.mockReset()
@@ -210,8 +210,8 @@ describe('useFirebaseAuthStore', () => {
)
setActivePinia(createTestingPinia({ stubActions: false }))
const storeModule = await import('@/stores/firebaseAuthStore')
store = storeModule.useFirebaseAuthStore()
const storeModule = await import('@/stores/authStore')
store = storeModule.useAuthStore()
})
it("should not increment tokenRefreshTrigger on the user's first ID token event", () => {

View File

@@ -49,14 +49,14 @@ export type BillingPortalTargetTier = NonNullable<
>['application/json']
>['target_tier']
export class FirebaseAuthStoreError extends Error {
export class AuthStoreError extends Error {
constructor(message: string) {
super(message)
this.name = 'FirebaseAuthStoreError'
this.name = 'AuthStoreError'
}
}
export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
export const useAuthStore = defineStore('auth', () => {
const { flags } = useFeatureFlags()
// State
@@ -241,9 +241,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
try {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(
t('toastMessages.userNotAuthenticated')
)
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const response = await fetch(buildApiUrl('/customers/balance'), {
@@ -259,7 +257,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
return null
}
const errorData = await response.json()
throw new FirebaseAuthStoreError(
throw new AuthStoreError(
t('toastMessages.failedToFetchBalance', {
error: errorData.message
})
@@ -279,7 +277,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
const createCustomer = async (): Promise<CreateCustomerResponse> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const createCustomerRes = await fetch(buildApiUrl('/customers'), {
@@ -290,7 +288,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
}
})
if (!createCustomerRes.ok) {
throw new FirebaseAuthStoreError(
throw new AuthStoreError(
t('toastMessages.failedToCreateCustomer', {
error: createCustomerRes.statusText
})
@@ -300,7 +298,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
const createCustomerResJson: CreateCustomerResponse =
await createCustomerRes.json()
if (!createCustomerResJson?.id) {
throw new FirebaseAuthStoreError(
throw new AuthStoreError(
t('toastMessages.failedToCreateCustomer', {
error: 'No customer ID returned'
})
@@ -431,7 +429,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
/** Update password for current user */
const _updatePassword = async (newPassword: string): Promise<void> => {
if (!currentUser.value) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
await updatePassword(currentUser.value, newPassword)
}
@@ -441,7 +439,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
): Promise<CreditPurchaseResponse> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
// Ensure customer was created during login/registration
@@ -461,7 +459,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
if (!response.ok) {
const errorData = await response.json()
throw new FirebaseAuthStoreError(
throw new AuthStoreError(
t('toastMessages.failedToInitiateCreditPurchase', {
error: errorData.message
})
@@ -481,7 +479,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
): Promise<AccessBillingPortalResponse> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const response = await fetch(buildApiUrl('/customers/billing'), {
@@ -497,7 +495,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
if (!response.ok) {
const errorData = await response.json()
throw new FirebaseAuthStoreError(
throw new AuthStoreError(
t('toastMessages.failedToAccessBillingPortal', {
error: errorData.message
})

View File

@@ -50,12 +50,12 @@ vi.mock('@/stores/userStore', () => ({
}))
}))
const mockIsFirebaseInitialized = ref(false)
const mockIsFirebaseAuthenticated = ref(false)
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
isInitialized: mockIsFirebaseInitialized,
isAuthenticated: mockIsFirebaseAuthenticated
const mockIsAuthInitialized = ref(false)
const mockIsAuthAuthenticated = ref(false)
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
isInitialized: mockIsAuthInitialized,
isAuthenticated: mockIsAuthAuthenticated
}))
}))
@@ -67,8 +67,8 @@ vi.mock('@/platform/distribution/types', () => mockDistributionTypes)
describe('bootstrapStore', () => {
beforeEach(() => {
mockIsSettingsReady.value = false
mockIsFirebaseInitialized.value = false
mockIsFirebaseAuthenticated.value = false
mockIsAuthInitialized.value = false
mockIsAuthAuthenticated.value = false
mockNeedsLogin.value = false
mockDistributionTypes.isCloud = false
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -107,14 +107,14 @@ describe('bootstrapStore', () => {
expect(settingStore.isReady).toBe(false)
// Firebase initialized but user not yet authenticated
mockIsFirebaseInitialized.value = true
mockIsAuthInitialized.value = true
await nextTick()
expect(store.isI18nReady).toBe(false)
expect(settingStore.isReady).toBe(false)
// User authenticates (e.g. signs in on login page)
mockIsFirebaseAuthenticated.value = true
mockIsAuthAuthenticated.value = true
await bootstrapPromise
await vi.waitFor(() => {

View File

@@ -5,7 +5,7 @@ import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { api } from '@/scripts/api'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import { useUserStore } from '@/stores/userStore'
export const useBootstrapStore = defineStore('bootstrap', () => {
@@ -37,9 +37,7 @@ export const useBootstrapStore = defineStore('bootstrap', () => {
async function startStoreBootstrap() {
if (isCloud) {
const { isInitialized, isAuthenticated } = storeToRefs(
useFirebaseAuthStore()
)
const { isInitialized, isAuthenticated } = storeToRefs(useAuthStore())
await until(isInitialized).toBe(true)
await until(isAuthenticated).toBe(true)
}

View File

@@ -13,7 +13,7 @@ import type { SidebarTabExtension, ToastManager } from '@/types/extensionTypes'
import { useApiKeyAuthStore } from './apiKeyAuthStore'
import { useCommandStore } from './commandStore'
import { useExecutionErrorStore } from './executionErrorStore'
import { useFirebaseAuthStore } from './firebaseAuthStore'
import { useAuthStore } from './authStore'
import { useQueueSettingsStore } from './queueStore'
import { useBottomPanelStore } from './workspace/bottomPanelStore'
import { useSidebarTabStore } from './workspace/sidebarTabStore'
@@ -48,7 +48,7 @@ function workspaceStoreSetup() {
const dialog = useDialogService()
const bottomPanel = useBottomPanelStore()
const authStore = useFirebaseAuthStore()
const authStore = useAuthStore()
const apiKeyStore = useApiKeyAuthStore()
const firebaseUser = computed(() => authStore.currentUser)

View File

@@ -26,6 +26,7 @@
<ModelImportProgressDialog />
<AssetExportProgressDialog />
<ManagerProgressToast />
<DesktopCloudNotificationController />
<UnloadWindowConfirmDialog v-if="!isDesktop" />
<MenuHamburger />
</template>
@@ -63,6 +64,7 @@ import type { ServerConfig, ServerConfigValue } from '@/constants/serverConfig'
import { i18n, loadLocale } from '@/i18n'
import AssetExportProgressDialog from '@/platform/assets/components/AssetExportProgressDialog.vue'
import ModelImportProgressDialog from '@/platform/assets/components/ModelImportProgressDialog.vue'
import DesktopCloudNotificationController from '@/platform/cloud/notification/components/DesktopCloudNotificationController.vue'
import { isCloud, isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
@@ -78,7 +80,7 @@ import { useAppMode } from '@/composables/useAppMode'
import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useAuthStore } from '@/stores/authStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useModelStore } from '@/stores/modelStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
@@ -120,7 +122,7 @@ watch(linearMode, (isLinear) => {
})
const telemetry = useTelemetry()
const firebaseAuthStore = useFirebaseAuthStore()
const authStore = useAuthStore()
let hasTrackedLogin = false
watch(
@@ -308,7 +310,7 @@ void nextTick(() => {
const onGraphReady = () => {
runWhenGlobalIdle(() => {
// Track user login when app is ready in graph view (cloud only)
if (isCloud && firebaseAuthStore.isAuthenticated && !hasTrackedLogin) {
if (isCloud && authStore.isAuthenticated && !hasTrackedLogin) {
telemetry?.trackUserLoggedIn()
hasTrackedLogin = true
}