From c789cd5fa3310268583169b919ebf22177426041 Mon Sep 17 00:00:00 2001 From: Dante Date: Sat, 18 Jul 2026 19:40:15 -0400 Subject: [PATCH 1/2] fix(workspace): derive Members access from Team plan (#13810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Make Members eligibility follow the active billing plan instead of the personal/team workspace type. A personal workspace on a Team plan now gets the same Members experience as a team workspace on the same plan. ## Root cause The Members surface had three independent workspace-type gates: - `useTeamPlan` treated “team workspace + subscribed” as the Team-plan signal, so a personal workspace was always classified as not being on Team even when billing reported a Team plan. - `useMembersPanel` inherited the personal-workspace permission and layout defaults, which forced the single-user row, disabled Invite, and rendered the Team upgrade banner. - Member and pending-invite loading short-circuited for personal workspaces, so valid Team-plan members could not be fetched even if the UI gate was corrected. ## How this fixes it - Derive `hasTeamPlan`, active Team-plan, cancelled/lapsed, and loading states from billing plan identity plus subscription state. - Overlay Members permissions and table layout from the plan and workspace role. Team-plan owners can invite and manage members regardless of workspace type; personal plans keep the single-user experience. - Wait for billing initialization before deciding whether to fetch members/invites or show an upgrade state. - Remove the store-level personal-workspace short-circuits for member and invite APIs. - Keep workspace-container actions such as delete/leave governed by workspace type; only the Members entitlement moves to plan-based logic. ## Before / After ### Before A personal workspace with a Team plan was treated as a personal plan: Invite was disabled and the UI asked the user to upgrade to Team. Before: Personal Workspace on Team plan
incorrectly shows disabled Invite and Upgrade to Team ### After The same personal-workspace + Team-plan scenario loads the Team member list and enables owner member-management actions. After: Personal Workspace on Team plan shows
member list, Invite, roles, and member actions ## Review Focus - Team-plan owners receive member management regardless of workspace type. - Personal plans remain single-user. - Loading, cancelled, and lapsed states do not briefly expose an incorrect upgrade or invite state. - Workspace delete/leave behavior remains workspace-type based. ## Validation - 168 focused Vitest tests passed. - `pnpm typecheck` passed. - `pnpm lint` passed. - `pnpm knip` passed. - Cloud Playwright: 7/7 tests passed in `memberRoleChange.spec.ts`, including personal workspace + Team plan regression coverage. - Pre-commit style, format, lint, and type checks passed. --- browser_tests/fixtures/data/cloudWorkspace.ts | 11 +- .../helpers/CloudWorkspaceMockHelper.ts | 30 ++-- .../tests/dialogs/memberRoleChange.spec.ts | 35 +++++ .../settings/MembersPanelContent.test.ts | 39 ++++-- .../dialogs/settings/MembersPanelContent.vue | 16 +-- .../settings/WorkspacePanelContent.test.ts | 51 +++++-- .../settings/WorkspacePanelContent.vue | 20 +-- .../composables/useMembersPanel.test.ts | 130 ++++++++++++++---- .../workspace/composables/useMembersPanel.ts | 84 +++++++++-- .../workspace/composables/useTeamPlan.test.ts | 67 +++++---- .../workspace/composables/useTeamPlan.ts | 44 +++--- .../stores/teamWorkspaceStore.test.ts | 25 +++- .../workspace/stores/teamWorkspaceStore.ts | 11 +- 13 files changed, 402 insertions(+), 161 deletions(-) diff --git a/browser_tests/fixtures/data/cloudWorkspace.ts b/browser_tests/fixtures/data/cloudWorkspace.ts index c175abed1ee..73b05ee86ab 100644 --- a/browser_tests/fixtures/data/cloudWorkspace.ts +++ b/browser_tests/fixtures/data/cloudWorkspace.ts @@ -9,7 +9,8 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types' // `/api/features` is the remote-config source: production builds resolve the // workspaces flag from it (the `ff:` localStorage override is dev-only). export const WORKSPACE_FEATURE_FLAG: RemoteConfig = { - team_workspaces_enabled: true + team_workspaces_enabled: true, + billing_control_enabled: true } export const TEAM_WORKSPACE: WorkspaceWithRole = { @@ -67,21 +68,21 @@ export const DEFAULT_TEAM_MEMBERS: Member[] = [ MEMBER_JOHN ] +const TEAM_PLAN_SLUG = 'team-pro-monthly' + export const TEAM_BILLING_STATUS: BillingStatusResponse = { is_active: true, subscription_status: 'active', subscription_tier: 'PRO', subscription_duration: 'MONTHLY', - plan_slug: 'pro-monthly', + plan_slug: TEAM_PLAN_SLUG, billing_status: 'paid', has_funds: true, renewal_date: '2099-02-20T00:00:00Z' } -// `max_seats > 1` on the current plan is what flips `isOnTeamPlan`, which gates -// the whole role-management UI. export const TEAM_PRO_PLAN: Plan = { - slug: 'pro-monthly', + slug: TEAM_PLAN_SLUG, tier: 'PRO', duration: 'MONTHLY', price_cents: 10000, diff --git a/browser_tests/fixtures/helpers/CloudWorkspaceMockHelper.ts b/browser_tests/fixtures/helpers/CloudWorkspaceMockHelper.ts index b21d2c47288..ceae63b091e 100644 --- a/browser_tests/fixtures/helpers/CloudWorkspaceMockHelper.ts +++ b/browser_tests/fixtures/helpers/CloudWorkspaceMockHelper.ts @@ -1,6 +1,9 @@ import type { Page, Route } from '@playwright/test' -import type { Member } from '@/platform/workspace/api/workspaceApi' +import type { + Member, + WorkspaceWithRole +} from '@/platform/workspace/api/workspaceApi' import { mockSystemStats } from '@e2e/fixtures/data/systemStats' import { @@ -41,18 +44,22 @@ export class CloudWorkspaceMockHelper { constructor(private readonly page: Page) {} async setup( - members: Member[] = DEFAULT_TEAM_MEMBERS + members: Member[] = DEFAULT_TEAM_MEMBERS, + activeWorkspace: WorkspaceWithRole = TEAM_WORKSPACE ): Promise { - const state = await this.mockBoot(members) + const state = await this.mockBoot(members, activeWorkspace) await new CloudAuthHelper(this.page).mockAuth() - await this.page.addInitScript(() => { + await this.page.addInitScript((workspaceId) => { localStorage.setItem('Comfy.userId', 'test-user-e2e') - localStorage.setItem('Comfy.Workspace.LastWorkspaceId', 'ws-team') - }) + localStorage.setItem('Comfy.Workspace.LastWorkspaceId', workspaceId) + }, activeWorkspace.id) return state } - private async mockBoot(members: Member[]): Promise { + private async mockBoot( + members: Member[], + activeWorkspace: WorkspaceWithRole + ): Promise { const state: MemberMockState = { members: members.map((m) => ({ ...m })), patches: [] @@ -93,11 +100,11 @@ export class CloudWorkspaceMockHelper { await page.route('**/api/auth/session', (r) => r.fulfill(jsonRoute({ token: 'mock-workspace-token' })) ) - await mockWorkspaceTokenMint(page, TEAM_WORKSPACE) + await mockWorkspaceTokenMint(page, activeWorkspace) await page.route('**/releases**', (r) => r.fulfill(jsonRoute([]))) await page.route('**/api/workspaces', (r) => - r.fulfill(jsonRoute({ workspaces: [TEAM_WORKSPACE] })) + r.fulfill(jsonRoute({ workspaces: [activeWorkspace] })) ) await page.route('**/api/workspace/members**', (route: Route) => { @@ -140,7 +147,10 @@ export class CloudWorkspaceMockHelper { ) await page.route('**/api/billing/plans', (r) => r.fulfill( - jsonRoute({ current_plan_slug: 'pro-monthly', plans: [TEAM_PRO_PLAN] }) + jsonRoute({ + current_plan_slug: TEAM_PRO_PLAN.slug, + plans: [TEAM_PRO_PLAN] + }) ) ) diff --git a/browser_tests/tests/dialogs/memberRoleChange.spec.ts b/browser_tests/tests/dialogs/memberRoleChange.spec.ts index e17890ed310..6768031fe17 100644 --- a/browser_tests/tests/dialogs/memberRoleChange.spec.ts +++ b/browser_tests/tests/dialogs/memberRoleChange.spec.ts @@ -6,11 +6,13 @@ import type { Member } from '@/platform/workspace/api/workspaceApi' import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' import { CREATOR, + DEFAULT_TEAM_MEMBERS, MEMBER_JANE, MEMBER_JOHN, VIEWER } from '@e2e/fixtures/data/cloudWorkspace' import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper' +import { workspace } from '@e2e/fixtures/utils/workspaceMocks' // Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots // against fully mocked endpoints; `comfyPage` would try to reach the OSS @@ -70,6 +72,39 @@ async function openChangeRoleSubmenu(page: Page) { ).toBeVisible() } +test.describe('Members plan gating', { tag: '@cloud' }, () => { + test('personal workspace with a Team plan gets member management', async ({ + page + }) => { + await new CloudWorkspaceMockHelper(page).setup( + DEFAULT_TEAM_MEMBERS, + workspace('personal', 'owner') + ) + const content = await openMembersTab(page) + + const inviteButton = content.getByRole('button', { + name: 'Invite member' + }) + await expect(inviteButton).toBeEnabled() + await expect( + content.getByRole('button', { name: 'Role', exact: true }) + ).toBeVisible() + await expect( + content.getByText(MEMBER_JANE.email, { exact: true }) + ).toBeVisible() + await expect( + content.getByRole('button', { name: 'Upgrade to Team' }) + ).toHaveCount(0) + + await inviteButton.click() + await expect( + page.getByRole('heading', { + name: 'Invite members to this workspace' + }) + ).toBeVisible() + }) +}) + test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => { test.describe.configure({ timeout: 60_000 }) diff --git a/src/platform/workspace/components/dialogs/settings/MembersPanelContent.test.ts b/src/platform/workspace/components/dialogs/settings/MembersPanelContent.test.ts index 9d0cd855f08..186bbb2a62f 100644 --- a/src/platform/workspace/components/dialogs/settings/MembersPanelContent.test.ts +++ b/src/platform/workspace/components/dialogs/settings/MembersPanelContent.test.ts @@ -25,7 +25,8 @@ const { mockOriginalOwnerId, mockFilteredMembers, mockFilteredPendingInvites, - mockIsPersonalWorkspace, + mockHasTeamPlan, + mockIsPlanLoading, mockIsOnTeamPlan, mockHasMultipleMembers, mockShowSearch, @@ -51,7 +52,8 @@ const { mockIsInviteDisabled: ref(false), mockFilteredMembers: ref([]), mockFilteredPendingInvites: ref([]), - mockIsPersonalWorkspace: ref(false), + mockHasTeamPlan: ref(true), + mockIsPlanLoading: ref(false), mockIsOnTeamPlan: ref(true), mockActiveView: ref<'active' | 'pending'>('active'), mockSearchQuery: ref(''), @@ -86,7 +88,10 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({ searchQuery: mockSearchQuery, activeView: mockActiveView, maxSeats: computed(() => 20), + hasTeamPlan: mockHasTeamPlan, + isPlanLoading: mockIsPlanLoading, isOnTeamPlan: mockIsOnTeamPlan, + hasLapsedTeamPlan: computed(() => false), hasMultipleMembers: mockHasMultipleMembers, showSearch: mockShowSearch, showViewTabs: mockShowViewTabs, @@ -111,7 +116,6 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({ mockFilteredMembers.value.map((m) => [m.id, mockMemberMenuItems()]) ) ), - isPersonalWorkspace: mockIsPersonalWorkspace, members: mockMembers, pendingInvites: mockPendingInvites, permissions: mockPermissions, @@ -205,7 +209,8 @@ describe('MembersPanelContent', () => { mockOriginalOwnerId.value = null mockFilteredMembers.value = [] mockFilteredPendingInvites.value = [] - mockIsPersonalWorkspace.value = false + mockHasTeamPlan.value = true + mockIsPlanLoading.value = false mockIsOnTeamPlan.value = true mockHasMultipleMembers.value = true mockShowSearch.value = true @@ -239,9 +244,9 @@ describe('MembersPanelContent', () => { } }) - describe('personal workspace', () => { + describe('personal plan', () => { beforeEach(() => { - mockIsPersonalWorkspace.value = true + mockHasTeamPlan.value = false mockIsOnTeamPlan.value = false mockHasMultipleMembers.value = false mockShowSearch.value = false @@ -275,7 +280,7 @@ describe('MembersPanelContent', () => { }) }) - describe('team workspace - member list', () => { + describe('Team plan member list', () => { it('shows the Role column header and member roles', () => { mockFilteredMembers.value = [ createMember({ role: 'owner', email: 'boss@test.com' }), @@ -301,6 +306,9 @@ describe('MembersPanelContent', () => { renderComponent() expect(screen.getByText('Alice')).toBeTruthy() expect(screen.getByText('Bob')).toBeTruthy() + expect( + screen.queryByText('workspacePanel.members.upsellBanner') + ).toBeNull() }) it('shows more options button for non-current members', () => { @@ -431,6 +439,7 @@ describe('MembersPanelContent', () => { describe('not on team plan', () => { beforeEach(() => { + mockHasTeamPlan.value = false mockIsOnTeamPlan.value = false mockShowSearch.value = false mockShowViewTabs.value = false @@ -444,6 +453,7 @@ describe('MembersPanelContent', () => { }) it('hides the upsell banner when on a team plan', () => { + mockHasTeamPlan.value = true mockIsOnTeamPlan.value = true renderComponent() expect( @@ -469,10 +479,18 @@ describe('MembersPanelContent', () => { renderComponent() expect(screen.queryByText('workspacePanel.members.contactUs')).toBeNull() }) + + it('does not show an upgrade banner while plan state is loading', () => { + mockIsPlanLoading.value = true + renderComponent() + expect( + screen.queryByText('workspacePanel.members.upsellBanner') + ).toBeNull() + }) }) describe('contact us footer', () => { - it('opens discord in a new tab for team workspaces on a team plan', async () => { + it('opens discord in a new tab on a Team plan', async () => { const openSpy = vi.spyOn(window, 'open').mockReturnValue(null) renderComponent() expect( @@ -489,10 +507,9 @@ describe('MembersPanelContent', () => { openSpy.mockRestore() }) - it('is hidden in personal workspaces', () => { - mockIsPersonalWorkspace.value = true + it('is shown whenever the active plan is Team', () => { renderComponent() - expect(screen.queryByText('workspacePanel.members.contactUs')).toBeNull() + expect(screen.getByText('workspacePanel.members.contactUs')).toBeTruthy() }) }) diff --git a/src/platform/workspace/components/dialogs/settings/MembersPanelContent.vue b/src/platform/workspace/components/dialogs/settings/MembersPanelContent.vue index cd7f1b9be0a..a94c5043cf1 100644 --- a/src/platform/workspace/components/dialogs/settings/MembersPanelContent.vue +++ b/src/platform/workspace/components/dialogs/settings/MembersPanelContent.vue @@ -8,7 +8,7 @@