Skip to content

Commit 848cd39

Browse files
dante01yoonampagentcomfydesigner
authored
feat(workspace): enable local credit workspace switching FE-1584 (Comfy-Org#15164)
## Why Local/Desktop users cannot select the workspace that owns their subscription or credits. The existing workspace and billing clients also resolve through `api.apiURL()`, which points at the local ComfyUI server off Cloud, so simply exposing the Cloud switcher leaves workspace hydration dormant and returns 404s. ## Root cause The Cloud-only UI guard hid the switcher, but the deeper boundary was also Cloud-only: - workspace token exchange, workspace listing, and `/billing/*` used same-origin URLs - generic auth helpers mixed Firebase user identity with workspace-scoped identity - Cloud workspace switching reloads the app and changes workflow-persistence namespaces, which is incorrect for Local/Desktop where workspaces are credit wallets only ## Change - Expose the existing workspace switcher in Local/Desktop after Firebase auth hydrates; keep workspace creation Cloud-only. - Route workspace auth, listing, and billing requests to the build's Firebase-paired Cloud gateway (`cloud.comfy.org` in production, `testcloud.comfy.org` in non-production) while Cloud remains same-origin. - Separate Firebase-authenticated `/customers/*` calls from workspace-authenticated billing and partner-node execution. - Switch the active local credit context without reloading or moving local workflow persistence; persist the selected wallet per device. - Keep Add credits permission-aware and return completed/pending top-ups to Settings → Credits on Local/Desktop. ### Implementation scope and rationale | Area | Change | Why it is required | | --- | --- | --- | | Workspace API routing | Resolve workspace and billing requests through the Firebase-paired Cloud gateway outside the Cloud distribution. | Local `/api` targets the local ComfyUI server, while workspace and billing state is owned by Cloud ingest. | | Authentication boundary | Separate Firebase user authentication from workspace-scoped authentication, including partner-node queue execution. | A Firebase token identifies the user; the workspace token deterministically identifies the wallet that owns credits and receives usage charges. | | Workspace bootstrap | Share and await the in-flight Local workspace initialization, and fail closed when no active workspace can be established. | Prompt submission during bootstrap must not run without credentials or silently fall back to the personal workspace. | | Local switch lifecycle | Switch the active credit workspace without reloading the document. | Cloud reload semantics are unnecessary for Local and would interrupt the current local workflow. | | Workflow persistence | Keep Local workflow storage in the personal namespace when the credit workspace changes. | Local workspace selection changes the billing wallet only; it must not move, clear, or split locally stored workflows. | | Billing routing | Use workspace billing after Local workspace hydration. | Balances, plans, billing events, and top-ups must follow the selected credit workspace rather than the personal account. | | Billing permissions | Keep Credits visible while hiding Add credits for members who cannot top up. | Backend top-up operations are owner-only, so the UI must not offer an action the selected workspace role cannot perform. | | Top-up completion | Return completed or pending Local top-ups to Settings → Credits. | Local does not expose the Cloud workspace-management destination used by the existing Cloud flow. | | Cloud customer endpoints | Explicitly retain Firebase authentication for user-scoped customer and subscription calls. | Changing generic auth precedence must not send a workspace token to endpoints whose contract is user-scoped. | | Feature-flag isolation | Disable `unified_cloud_auth` outside the Cloud distribution. | Unified Cloud JWT minting is not available in Local/Desktop; accepting that flag there would break workspace discovery and execution authentication. | | Regression coverage | Add focused unit tests and a Local Playwright workspace-switching flow. | The tests protect workspace attribution, Cloud-gateway routing, no-reload switching, workflow retention, permissions, and bootstrap race handling. | Backend dependency: [cloud#6635](Comfy-Org/cloud#6635) is required and merged. Its Local/Desktop CORS scope covers `POST /api/auth/token`, `GET /api/workspaces`, and `GET`/`POST /api/billing/*`; Local keeps workspace creation hidden. The backend PR is merged into `main`, and its production manifest contains the loopback origins. A live preflight currently passes on `testcloud.comfy.org`, while `cloud.comfy.org` still omits `Access-Control-Allow-Origin` for loopback origins. No additional backend code change is currently identified; the remaining release check is to confirm the merged configuration has rolled out to the production runtime and repeat the preflight. [Comfy-Org#15151](Comfy-Org#15151) independently fixes the existing Settings → Credits activity spinner and billing-portal rail. ## Plan / QA 1. Review the auth and persistence boundaries in this PR. 2. Land Comfy-Org#15151 so activity and Invoice History follow the selected billing rail. 3. Confirm the merged cloud#6635 configuration has rolled out to the production runtime and repeat the live preflight. 4. Verify on a Desktop build against the deployed gateway: switch/relaunch, popover and Settings balances, top-up attribution, partner-node debit and allowlist, member permissions, and zero-credit local runs. Regression audit added a focused Local Playwright flow. It caught and fixed two release blockers: Local rendered the legacy profile popover even after workspace hydration, and `unified_cloud_auth=true` incorrectly selected an unavailable Cloud JWT outside the Cloud distribution. The E2E verifies workspace/role rendering, workspace token exchange, Cloud-gateway billing routing, and that switching neither reloads the document nor loses the open workflow tab. A second auth/accounting audit found two queue-time races: a prompt could be submitted without a workspace token while Local workspace bootstrap was in flight, or fall back to the personal Firebase identity after bootstrap failed. Queue authentication now shares and awaits the in-flight workspace initialization and fails closed if no active workspace can be established. Identity-generation guards prevent a previous user's initialization from overwriting the next user's state. Design of record: [MVP before/after](https://www.figma.com/design/CkFTD4c20PyRGpNVAJgpfV/Team-Plan---Workspaces?node-id=5874-73181) · [popover and switcher](https://www.figma.com/design/CkFTD4c20PyRGpNVAJgpfV/Team-Plan---Workspaces?node-id=5874-72771) The implementation references those prototype states: the profile popover gains the workspace row, the selector opens to the left, and the selected workspace is highlighted with a check while roles remain visible. Local-specific adaptations are the compact single-line trigger and the retained Credits entry, because this scope adds wallet switching without exposing Cloud workspace management. | AS IS — Local profile menu | TO BE — Local workspace selector | | --- | --- | | ![AS IS Local profile menu](https://ampcode.com/user-content/artifacts/5112da3b9ef62052997070dfef05a45f2febbb34c6c6781c6aebbec951cffc93-file.png) | ![TO BE Local workspace selector](https://ampcode.com/user-content/artifacts/74da3cdb517dd10ba82e5abbbe4b5d100c76107defdb0d8b4ac0950a63299fbe-file.png) | Captured from the actual Local frontend at 1920×1200 with deterministic auth/workspace/billing API fixtures; no Figma image is substituted for the implementation capture. ## Validation - Focused Local workspace Playwright E2E: 1/1 passed on Chromium before the final auth-race patch; final local rerun was blocked during fixture setup because the 8188 ComfyUI test backend was unavailable, before any product assertion ran - Final focused workspace/auth/queue unit suites: 316/316 passed when run in their stable focused groups - Earlier affected unit/component suites: 650/650 passed before rebase; 594/594 passed after rebase - `pnpm typecheck` - targeted ESLint and oxfmt checks - `pnpm knip` reports only existing repository-wide unused dependency/export findings - Real-app screenshot Playwright run: 2/2 passed (AS IS without a workspace roster; TO BE with the selector open) - Live CORS preflight: staging passes; production runtime rollout/sync verification remains the release blocker Linear: [FE-1584](https://linear.app/comfyorg/issue/FE-1584/add-workspace-switcher-to-localdesktop-credits-only-no-settings) --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: comfydesigner <alextov@comfy.org>
1 parent bfb6cd0 commit 848cd39

49 files changed

Lines changed: 1206 additions & 135 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

browser_tests/fixtures/workspaceSwitcherFixture.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
22

33
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
4+
import {
5+
EMPTY_BILLING_BALANCE,
6+
EMPTY_BILLING_PLANS,
7+
ENDED_STANDARD_BILLING_STATUS
8+
} from '@e2e/fixtures/data/cloudWorkspace'
49
import {
510
WORKSPACE_SWITCHER_REMOTE_CONFIG,
611
WORKSPACE_SWITCHER_WORKSPACES
@@ -51,6 +56,15 @@ export const workspaceSwitcherTest = comfyPageFixture.extend({
5156
await page.route('**/api/auth/session', (route) =>
5257
route.fulfill({ status: 204 })
5358
)
59+
await page.route('**/api/billing/status', (route) =>
60+
route.fulfill(jsonRoute(ENDED_STANDARD_BILLING_STATUS))
61+
)
62+
await page.route('**/api/billing/balance', (route) =>
63+
route.fulfill(jsonRoute(EMPTY_BILLING_BALANCE))
64+
)
65+
await page.route('**/api/billing/plans', (route) =>
66+
route.fulfill(jsonRoute(EMPTY_BILLING_PLANS))
67+
)
5468

5569
await use(page)
5670
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { expect } from '@playwright/test'
2+
3+
import {
4+
PERSONAL_WORKSPACE_NAME,
5+
TEAM_WORKSPACE_NAME
6+
} from '@e2e/fixtures/data/workspaceSwitcher'
7+
import { workspaceSwitcherTest as test } from '@e2e/fixtures/workspaceSwitcherFixture'
8+
9+
test.describe('Local workspace switcher', { tag: '@auth' }, () => {
10+
test.describe.configure({ timeout: 60_000 })
11+
12+
test('switches the active workspace without reloading or losing the workflow', async ({
13+
comfyPage
14+
}) => {
15+
const page = comfyPage.page
16+
const localOrigin = new URL(page.url()).origin
17+
const billingRequestUrls: string[] = []
18+
const localBillingRequestUrls: string[] = []
19+
const billingAuthorizationHeaders: string[] = []
20+
let tokenRequestBody: unknown
21+
22+
page.on('request', (request) => {
23+
if (!request.url().includes('/api/billing/')) return
24+
billingRequestUrls.push(request.url())
25+
billingAuthorizationHeaders.push(request.headers().authorization ?? '')
26+
if (new URL(request.url()).origin === localOrigin) {
27+
localBillingRequestUrls.push(request.url())
28+
}
29+
})
30+
page.on('request', (request) => {
31+
if (
32+
request.method() === 'POST' &&
33+
request.url().endsWith('/api/auth/token')
34+
) {
35+
tokenRequestBody = request.postDataJSON()
36+
}
37+
})
38+
39+
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
40+
await comfyPage.settings.setSetting(
41+
'Comfy.Workflow.WorkflowTabsPosition',
42+
'Topbar'
43+
)
44+
const workflowName = `local-workspace-${Date.now().toString(36)}`
45+
await comfyPage.menu.topbar.saveWorkflow(workflowName)
46+
await page.evaluate(() => {
47+
document.body.dataset.workspaceSwitchDocument = 'original'
48+
})
49+
50+
await page.getByRole('button', { name: 'Current user' }).click()
51+
await expect(page.getByTestId('workspace-switcher-trigger')).toContainText(
52+
PERSONAL_WORKSPACE_NAME
53+
)
54+
await page.getByTestId('workspace-switcher-trigger').click()
55+
56+
const panel = page.getByTestId('workspace-switcher-panel')
57+
await expect(panel.getByText(PERSONAL_WORKSPACE_NAME)).toBeVisible()
58+
await expect(panel.getByText(TEAM_WORKSPACE_NAME)).toBeVisible()
59+
await expect(panel.getByText('Owner')).toHaveCount(2)
60+
await expect(panel.getByText('Member')).toHaveCount(1)
61+
const scopeCaption = panel.getByText(
62+
'Workspaces only affect which credits you use.'
63+
)
64+
await expect(scopeCaption).toBeVisible()
65+
await scopeCaption.locator('..').locator('.pi-info-circle').hover()
66+
await expect(page.getByRole('tooltip')).toHaveText(
67+
'Runs that use partner nodes spend credits from this workspace. Unlike on Cloud, every workspace saves to your usual output folder.'
68+
)
69+
70+
await panel.getByText(TEAM_WORKSPACE_NAME, { exact: true }).click()
71+
72+
await expect(page.getByTestId('workspace-switcher-trigger')).toContainText(
73+
TEAM_WORKSPACE_NAME
74+
)
75+
await expect.poll(() => billingRequestUrls.length).toBeGreaterThan(0)
76+
expect(tokenRequestBody).toEqual({ workspace_id: 'ws-team' })
77+
expect(billingRequestUrls).toEqual(
78+
expect.arrayContaining([
79+
expect.stringMatching(
80+
/^https:\/\/testcloud\.comfy\.org\/api\/billing\//
81+
)
82+
])
83+
)
84+
expect(localBillingRequestUrls).toEqual([])
85+
await expect
86+
.poll(() => billingAuthorizationHeaders)
87+
.toContain('Bearer mock-workspace-token-ws-team')
88+
await expect
89+
.poll(() => comfyPage.menu.topbar.getTabNames())
90+
.toContain(workflowName)
91+
expect(
92+
await page.evaluate(() => document.body.dataset.workspaceSwitchDocument)
93+
).toBe('original')
94+
})
95+
})

browser_tests/tests/workspaceSwitcher.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ test.describe('Workspace switcher', { tag: '@cloud' }, () => {
4242

4343
const panel = page.getByTestId('workspace-switcher-panel')
4444
await expect(panel).toBeVisible()
45+
await expect(
46+
panel.getByText('Workspaces only affect which credits you use.')
47+
).toHaveCount(0)
4548

4649
const profileMenu = page.locator('.current-user-popover')
4750
const panelBox = await panel.boundingBox()

src/components/topbar/CurrentUserButton.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,4 +217,25 @@ describe('CurrentUserButton', () => {
217217
expect(screen.getByText('WorkspaceProfilePic')).toBeInTheDocument()
218218
expect(screen.queryByText('Avatar')).not.toBeInTheDocument()
219219
})
220+
221+
it('shows WorkspaceProfilePic for an active local team workspace', () => {
222+
mockTeamWorkspaceStore.initState.value = 'ready'
223+
mockTeamWorkspaceStore.isInPersonalWorkspace.value = false
224+
mockTeamWorkspaceStore.workspaceName.value = 'My Team'
225+
226+
renderComponent()
227+
228+
expect(screen.getByText('WorkspaceProfilePic')).toBeInTheDocument()
229+
expect(screen.queryByText('Avatar')).not.toBeInTheDocument()
230+
})
231+
232+
it('shows workspace actions after local workspace initialization', async () => {
233+
mockTeamWorkspaceStore.initState.value = 'ready'
234+
const { user } = renderComponent()
235+
236+
await user.click(screen.getByRole('button', { name: 'Current user' }))
237+
238+
expect(screen.getByText('Workspace Popover Content')).toBeInTheDocument()
239+
expect(screen.queryByText('Popover Content')).not.toBeInTheDocument()
240+
})
220241
})

src/components/topbar/CurrentUserButton.vue

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
@show="onPopoverShow"
5050
>
5151
<CurrentUserPopoverWorkspace
52-
v-if="isCloud"
52+
v-if="showWorkspacePopover"
5353
ref="workspacePopoverContent"
5454
:account-actions-only="initState !== 'ready'"
5555
@close="closePopover"
@@ -101,7 +101,10 @@ const showWorkspaceSkeleton = computed(
101101
() => isCloud && initState.value === 'loading'
102102
)
103103
const showWorkspaceIcon = computed(
104-
() => isCloud && initState.value === 'ready' && !isInPersonalWorkspace.value
104+
() => initState.value === 'ready' && !isInPersonalWorkspace.value
105+
)
106+
const showWorkspacePopover = computed(
107+
() => isCloud || initState.value === 'ready'
105108
)
106109
107110
const workspaceName = computed(() => {

src/components/topbar/CurrentUserPopoverLegacy.test.ts

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createTestingPinia } from '@pinia/testing'
12
import { render, screen } from '@testing-library/vue'
23
import userEvent from '@testing-library/user-event'
34
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -68,6 +69,7 @@ const mockTier = ref<SubscriptionInfo['tier']>('CREATOR')
6869
const mockSubscription = ref<SubscriptionInfo | null>(makeSubscription())
6970
const mockBalance = ref<BalanceInfo | null>(null)
7071
const mockIsLoading = ref(false)
72+
const mockIsTeamPlan = ref(false)
7173

7274
vi.mock('@/composables/billing/useBillingContext', () => ({
7375
useBillingContext: vi.fn(() => ({
@@ -76,6 +78,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
7678
subscription: mockSubscription,
7779
balance: mockBalance,
7880
isLoading: mockIsLoading,
81+
isTeamPlan: mockIsTeamPlan,
7982
fetchBalance: mockFetchBalance
8083
}))
8184
}))
@@ -121,7 +124,7 @@ describe('CurrentUserPopoverLegacy', () => {
121124
mockIsLoading.value = false
122125
})
123126

124-
function renderComponent() {
127+
function renderComponent(teamWorkspaceState?: Record<string, unknown>) {
125128
const i18n = createI18n({
126129
legacy: false,
127130
locale: 'en',
@@ -132,7 +135,15 @@ describe('CurrentUserPopoverLegacy', () => {
132135

133136
render(CurrentUserPopoverLegacy, {
134137
global: {
135-
plugins: [i18n],
138+
plugins: [
139+
i18n,
140+
createTestingPinia({
141+
createSpy: vi.fn,
142+
initialState: teamWorkspaceState
143+
? { teamWorkspace: teamWorkspaceState }
144+
: {}
145+
})
146+
],
136147
stubs: {
137148
Divider: true
138149
}
@@ -382,4 +393,101 @@ describe('CurrentUserPopoverLegacy', () => {
382393
expect(screen.getByText('0')).toBeInTheDocument()
383394
})
384395
})
396+
describe('workspace selector (non-cloud)', () => {
397+
const workspace = (overrides: Record<string, unknown>) => ({
398+
isSubscribed: false,
399+
subscriptionPlan: null,
400+
subscriptionTier: null,
401+
members: [],
402+
pendingInvites: [],
403+
...overrides
404+
})
405+
406+
const readyWorkspaceState = {
407+
initState: 'ready',
408+
activeWorkspaceId: 'ws-personal',
409+
isFetchingWorkspaces: false,
410+
workspaces: [
411+
workspace({
412+
id: 'ws-personal',
413+
name: 'Personal Workspace',
414+
type: 'personal',
415+
role: 'owner'
416+
}),
417+
workspace({
418+
id: 'ws-team',
419+
name: 'Team Comfy',
420+
type: 'team',
421+
role: 'member'
422+
})
423+
]
424+
}
425+
426+
it('stays hidden while the workspace store is not hydrated', () => {
427+
renderComponent()
428+
429+
expect(screen.queryByTestId('workspace-switcher-trigger')).toBeNull()
430+
})
431+
432+
it.for(['ready', 'error'])(
433+
'stays hidden when workspace initialization is %s without workspaces',
434+
(initState) => {
435+
renderComponent({
436+
initState,
437+
activeWorkspaceId: null,
438+
isFetchingWorkspaces: false,
439+
workspaces: []
440+
})
441+
442+
expect(screen.queryByTestId('workspace-switcher-trigger')).toBeNull()
443+
}
444+
)
445+
446+
it('shows the trigger and opens the switcher once the store is ready', async () => {
447+
const { user } = renderComponent(readyWorkspaceState)
448+
449+
const trigger = screen.getByTestId('workspace-switcher-trigger')
450+
expect(trigger).toHaveAttribute('aria-expanded', 'false')
451+
expect(trigger).toHaveAttribute('aria-haspopup', 'menu')
452+
expect(trigger).toHaveAttribute(
453+
'aria-controls',
454+
'workspace-switcher-panel'
455+
)
456+
expect(screen.queryByTestId('workspace-switcher-panel')).toBeNull()
457+
458+
await user.click(trigger)
459+
460+
const panel = screen.getByTestId('workspace-switcher-panel')
461+
expect(panel).toBeInTheDocument()
462+
expect(panel).toHaveAttribute('id', 'workspace-switcher-panel')
463+
expect(panel).toHaveAttribute('role', 'menu')
464+
expect(trigger).toHaveAttribute('aria-expanded', 'true')
465+
})
466+
467+
it('closes the switcher on Escape or a click elsewhere', async () => {
468+
const { user } = renderComponent(readyWorkspaceState)
469+
const trigger = screen.getByTestId('workspace-switcher-trigger')
470+
471+
await user.click(trigger)
472+
await user.keyboard('{Escape}')
473+
expect(screen.queryByTestId('workspace-switcher-panel')).toBeNull()
474+
475+
await user.click(trigger)
476+
await user.click(screen.getByText('Test User'))
477+
expect(screen.queryByTestId('workspace-switcher-panel')).toBeNull()
478+
})
479+
480+
it('keeps credits visible but hides top-up for workspace members', () => {
481+
mockCanAccessSubscriptionFeatures.value = false
482+
renderComponent({
483+
...readyWorkspaceState,
484+
activeWorkspaceId: 'ws-team'
485+
})
486+
487+
expect(screen.getByTestId('manage-plan-menu-item')).toHaveTextContent(
488+
enMessages.credits.credits
489+
)
490+
expect(screen.queryByTestId('add-credits-button')).toBeNull()
491+
})
492+
})
385493
})

0 commit comments

Comments
 (0)