diff --git a/browser_tests/fixtures/data/cloudWorkspace.ts b/browser_tests/fixtures/data/cloudWorkspace.ts index c175abed1ee..e20dcff4025 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, + consolidated_billing_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..db6415cee0a 100644 --- a/browser_tests/tests/dialogs/memberRoleChange.spec.ts +++ b/browser_tests/tests/dialogs/memberRoleChange.spec.ts @@ -6,26 +6,18 @@ 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 // devtools backend during setup. -/** - * Member role change (Settings ▸ Workspace ▸ Members) — Figma 2993-15512. - * - * The viewer is a promoted owner (not the workspace creator), so the spec can - * distinguish the creator guard from the self guard: the creator row and the - * viewer's own row hide the row menu, every other row exposes - * "Change role ›" (Owner / Member) plus "Remove member". Promoting a member - * sends PATCH /api/workspace/members/:id {role}, flips the Role column, - * re-sorts the row under the creator, and the promoted owner stays demotable. - */ const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188' async function openMembersTab(page: Page): Promise { @@ -70,31 +62,84 @@ 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 expect(menuButton(memberRow(content, CREATOR.email))).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 }) - test('row menus respect creator and self guards', async ({ page }) => { - await new CloudWorkspaceMockHelper(page).setup() + test('additional workspace creator has actions while self does not', async ({ + page + }) => { + const state = await new CloudWorkspaceMockHelper(page).setup() const content = await openMembersTab(page) + const creatorRow = memberRow(content, CREATOR.email) - // US8/US9 — no row actions on the creator row (Liz) nor on the viewer's - // own row; the two plain members each expose a menu. await expect( menuButton(memberRow(content, MEMBER_JOHN.email)) ).toBeVisible() await expect( menuButton(memberRow(content, MEMBER_JANE.email)) ).toBeVisible() - await expect(menuButton(memberRow(content, CREATOR.email))).toHaveCount(0) + await expect(menuButton(creatorRow)).toBeVisible() await expect(menuButton(memberRow(content, VIEWER.email))).toHaveCount(0) - // US1/US12 — the row menu exposes Change role and the FE-768 remove flow. - await menuButton(memberRow(content, MEMBER_JANE.email)).click() + await menuButton(creatorRow).click() await expect( page.getByRole('menuitem', { name: 'Change role' }) ).toBeVisible() await page.getByRole('menuitem', { name: 'Remove member' }).click() await expect(page.getByText('Remove this member?')).toBeVisible() + await page.getByRole('button', { name: 'Cancel', exact: true }).click() + + await menuButton(creatorRow).click() + await openChangeRoleSubmenu(page) + await page + .getByRole('menuitemradio', { name: 'Member', exact: true }) + .click() + await expect( + page.getByRole('heading', { name: 'Demote Liz to member?' }) + ).toBeVisible() + await page.getByRole('button', { name: 'Demote to member' }).click() + + await expect(creatorRow.getByText('Member', { exact: true })).toBeVisible() + expect(state.patches).toEqual([ + { + url: expect.stringContaining('/api/workspace/members/u-liz'), + role: 'member' + } + ]) }) test('selecting the current role is a no-op', async ({ page }) => { @@ -146,9 +191,7 @@ test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => { page.getByText('Manage members, payment methods, and workspace settings') ).toBeVisible() await expect( - page.getByText( - 'Promote and demote other owners (except the workspace creator).' - ) + page.getByText('Promote members and demote eligible owners.') ).toBeVisible() await page.getByRole('button', { name: 'Cancel', exact: true }).click() diff --git a/src/components/dialog/content/subscription/CancelSubscriptionDialogContent.test.ts b/src/components/dialog/content/subscription/CancelSubscriptionDialogContent.test.ts index 7a8c7246bb4..a39f6c7d5de 100644 --- a/src/components/dialog/content/subscription/CancelSubscriptionDialogContent.test.ts +++ b/src/components/dialog/content/subscription/CancelSubscriptionDialogContent.test.ts @@ -54,6 +54,8 @@ const mockCloseDialog = vi.hoisted(() => vi.fn()) const mockToastAdd = vi.hoisted(() => vi.fn()) const mockTier = vi.hoisted(() => ({ value: 'STANDARD' as string | null })) const mockTrackCancellation = vi.hoisted(() => vi.fn()) +const mockShouldUseWorkspaceBilling = vi.hoisted(() => ({ value: false })) +const mockCanManageSubscriptionLifecycle = vi.hoisted(() => ({ value: true })) vi.mock('@/composables/billing/useBillingContext', () => ({ useBillingContext: vi.fn(() => ({ @@ -64,6 +66,25 @@ vi.mock('@/composables/billing/useBillingContext', () => ({ })) })) +vi.mock('@/composables/billing/useBillingRouting', () => ({ + useBillingRouting: () => ({ + shouldUseWorkspaceBilling: mockShouldUseWorkspaceBilling + }) +})) + +vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({ + useWorkspaceUI: () => ({ + permissions: { + get value() { + return { + canManageSubscriptionLifecycle: + mockCanManageSubscriptionLifecycle.value + } + } + } + }) +})) + vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => ({ trackSubscriptionCancellation: mockTrackCancellation @@ -107,6 +128,8 @@ describe('CancelSubscriptionDialogContent', () => { beforeEach(() => { vi.clearAllMocks() mockTier.value = 'STANDARD' + mockShouldUseWorkspaceBilling.value = false + mockCanManageSubscriptionLifecycle.value = true }) describe('cancellation telemetry', () => { @@ -240,6 +263,26 @@ describe('CancelSubscriptionDialogContent', () => { ) }) + it('does not cancel after the workspace role loses permission', async () => { + mockSubscription.value = null + mockShouldUseWorkspaceBilling.value = true + mockCanManageSubscriptionLifecycle.value = true + + renderComponent() + mockCanManageSubscriptionLifecycle.value = false + await userEvent.click( + screen.getByRole('button', { name: /^cancel subscription$/i }) + ) + + expect(mockCancelSubscription).not.toHaveBeenCalled() + expect(mockTrackCancellation).not.toHaveBeenCalledWith( + 'confirmed', + expect.anything() + ) + expect(mockToastAdd).not.toHaveBeenCalled() + expect(mockCloseDialog).not.toHaveBeenCalled() + }) + it('does not track cancellation failure when status refresh fails after cancellation succeeds', async () => { mockSubscription.value = null mockCancelSubscription.mockResolvedValueOnce(undefined) diff --git a/src/components/dialog/content/subscription/CancelSubscriptionDialogContent.vue b/src/components/dialog/content/subscription/CancelSubscriptionDialogContent.vue index 9b9d8f2c6ec..27a6b04aca3 100644 --- a/src/components/dialog/content/subscription/CancelSubscriptionDialogContent.vue +++ b/src/components/dialog/content/subscription/CancelSubscriptionDialogContent.vue @@ -50,8 +50,10 @@ import { useI18n } from 'vue-i18n' import Button from '@/components/ui/button/Button.vue' import { useBillingContext } from '@/composables/billing/useBillingContext' +import { useBillingRouting } from '@/composables/billing/useBillingRouting' import { useTelemetry } from '@/platform/telemetry' import type { SubscriptionCancellationMetadata } from '@/platform/telemetry/types' +import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI' import { useDialogStore } from '@/stores/dialogStore' import { parseIsoDateSafe } from '@/utils/dateTimeUtil' import { getErrorMessage } from '@/utils/errorUtil' @@ -65,6 +67,8 @@ const dialogStore = useDialogStore() const toast = useToast() const { cancelSubscription, fetchStatus, subscription, tier } = useBillingContext() +const { shouldUseWorkspaceBilling } = useBillingRouting() +const { permissions } = useWorkspaceUI() const telemetry = useTelemetry() const isLoading = ref(false) @@ -119,6 +123,13 @@ function onClose() { } async function onConfirmCancel() { + if ( + shouldUseWorkspaceBilling.value && + !permissions.value.canManageSubscriptionLifecycle + ) { + return + } + telemetry?.trackSubscriptionCancellation('confirmed', cancellationMetadata()) isLoading.value = true try { diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 6d6b1408d68..304c81dc823 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2929,7 +2929,7 @@ "promoteIntro": "They'll be able to:", "promotePermissionCredits": "Add additional credits", "promotePermissionManage": "Manage members, payment methods, and workspace settings", - "promotePermissionRoles": "Promote and demote other owners (except the workspace creator).", + "promotePermissionRoles": "Promote members and demote eligible owners.", "promoteConfirm": "Make owner", "demoteTitle": "Demote {name} to member?", "demoteMessage": "They'll lose admin access.", diff --git a/src/platform/cloud/subscription/components/SubscriptionFooterLinks.test.ts b/src/platform/cloud/subscription/components/SubscriptionFooterLinks.test.ts new file mode 100644 index 00000000000..80af44467aa --- /dev/null +++ b/src/platform/cloud/subscription/components/SubscriptionFooterLinks.test.ts @@ -0,0 +1,95 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createI18n } from 'vue-i18n' + +import SubscriptionFooterLinks from './SubscriptionFooterLinks.vue' + +const state = vi.hoisted(() => ({ + manageSubscription: vi.fn(), + handleLearnMoreClick: vi.fn(), + handleMessageSupport: vi.fn() +})) + +vi.mock('@/composables/billing/useBillingContext', () => ({ + useBillingContext: () => ({ + manageSubscription: state.manageSubscription + }) +})) + +vi.mock('@/composables/useExternalLink', () => ({ + useExternalLink: () => ({ + buildDocsUrl: vi.fn(() => 'https://docs.comfy.org/partner-nodes'), + docsPaths: { partnerNodesPricing: 'partner-nodes' } + }) +})) + +vi.mock( + '@/platform/cloud/subscription/composables/useSubscriptionActions', + () => ({ + useSubscriptionActions: () => ({ + isLoadingSupport: ref(false), + handleLearnMoreClick: state.handleLearnMoreClick, + handleMessageSupport: state.handleMessageSupport + }) + }) +) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + subscription: { + learnMore: 'Learn more', + partnerNodesPricingTable: 'Partner Nodes pricing', + messageSupport: 'Message support', + invoiceHistory: 'Invoice history' + } + } + } +}) + +function renderComponent(showInvoiceHistory?: boolean) { + return render(SubscriptionFooterLinks, { + props: showInvoiceHistory === undefined ? {} : { showInvoiceHistory }, + global: { + plugins: [i18n], + stubs: { + Button: { + props: ['loading'], + emits: ['click'], + template: '' + } + } + } + }) +} + +describe('SubscriptionFooterLinks', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('keeps Invoice history visible by default for legacy billing', async () => { + const user = userEvent.setup() + renderComponent() + + await user.click(screen.getByRole('button', { name: 'Invoice history' })) + + expect(state.manageSubscription).toHaveBeenCalledOnce() + }) + + it('hides Invoice history without opening the payment portal', () => { + renderComponent(false) + + expect( + screen.queryByRole('button', { name: 'Invoice history' }) + ).not.toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Learn more' }) + ).toBeInTheDocument() + expect(state.manageSubscription).not.toHaveBeenCalled() + }) +}) diff --git a/src/platform/cloud/subscription/components/SubscriptionFooterLinks.vue b/src/platform/cloud/subscription/components/SubscriptionFooterLinks.vue index 753e9d1726e..d9f08c0ff10 100644 --- a/src/platform/cloud/subscription/components/SubscriptionFooterLinks.vue +++ b/src/platform/cloud/subscription/components/SubscriptionFooterLinks.vue @@ -31,6 +31,7 @@ -