Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions browser_tests/fixtures/data/cloudWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 20 additions & 10 deletions browser_tests/fixtures/helpers/CloudWorkspaceMockHelper.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<MemberMockState> {
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<MemberMockState> {
private async mockBoot(
members: Member[],
activeWorkspace: WorkspaceWithRole
): Promise<MemberMockState> {
const state: MemberMockState = {
members: members.map((m) => ({ ...m })),
patches: []
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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]
})
)
)

Expand Down
83 changes: 63 additions & 20 deletions browser_tests/tests/dialogs/memberRoleChange.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Locator> {
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({
Expand All @@ -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
Expand Down Expand Up @@ -107,6 +128,8 @@ describe('CancelSubscriptionDialogContent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTier.value = 'STANDARD'
mockShouldUseWorkspaceBilling.value = false
mockCanManageSubscriptionLifecycle.value = true
})

describe('cancellation telemetry', () => {
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)
Expand Down Expand Up @@ -119,6 +123,13 @@ function onClose() {
}

async function onConfirmCancel() {
if (
shouldUseWorkspaceBilling.value &&
!permissions.value.canManageSubscriptionLifecycle
) {
return
}

telemetry?.trackSubscriptionCancellation('confirmed', cancellationMetadata())
isLoading.value = true
try {
Expand Down
2 changes: 1 addition & 1 deletion src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Loading
Loading