Skip to content
Closed
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
22 changes: 10 additions & 12 deletions browser_tests/tests/dialogs/memberRoleChange.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,10 @@ import { workspace } from '@e2e/fixtures/utils/workspaceMocks'
* 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.
* distinguish personal-workspace creator protection from the self guard.
* 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'

Expand Down Expand Up @@ -92,6 +91,7 @@ test.describe('Members plan gating', { tag: '@cloud' }, () => {
await expect(
content.getByText(MEMBER_JANE.email, { exact: true })
).toBeVisible()
await expect(menuButton(memberRow(content, CREATOR.email))).toHaveCount(0)
await expect(
content.getByRole('button', { name: 'Upgrade to Team' })
).toHaveCount(0)
Expand All @@ -108,19 +108,19 @@ test.describe('Members plan gating', { tag: '@cloud' }, () => {
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 }) => {
test('row menus protect only the current user in an additional workspace', async ({
page
}) => {
await new CloudWorkspaceMockHelper(page).setup()
const content = await openMembersTab(page)

// 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(memberRow(content, CREATOR.email))).toBeVisible()
await expect(menuButton(memberRow(content, VIEWER.email))).toHaveCount(0)

// US1/US12 — the row menu exposes Change role and the FE-768 remove flow.
Expand Down Expand Up @@ -181,9 +181,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 and demote other owners.')
).toBeVisible()

await page.getByRole('button', { name: 'Cancel', exact: true }).click()
Expand Down
16 changes: 7 additions & 9 deletions browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,8 @@ import {
workspace
} from '@e2e/fixtures/utils/workspaceMocks'

/**
* The `?pricing=` deep link opens the pricing table on app load, gated to the
* original owner (canManageSubscriptionLifecycle). Drives a raw `page` so the
* cloud app boots against fully mocked endpoints, like the survey-gate spec.
*/
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'

// CloudAuthHelper.mockAuth() signs in as this email; the original-owner gate
// matches it against the members self-row.
const SELF_EMAIL = 'e2e@test.comfy.org'

// consolidated_billing_enabled routes personal workspaces to the unified
Expand Down Expand Up @@ -100,10 +93,15 @@ test.describe('Pricing table deep link', { tag: '@cloud' }, () => {
).toHaveAttribute('aria-pressed', 'true')
})

test('opens for a team original owner', async ({ page }) => {
test('opens for a promoted team owner', async ({ page }) => {
test.slow()
await setupCloudApp(page, workspace('team', 'owner'), [
member({ email: SELF_EMAIL, role: 'owner', is_original_owner: true })
member({
email: 'creator@test.comfy.org',
role: 'owner',
is_original_owner: true
}),
member({ email: SELF_EMAIL, role: 'owner' })
])

await page.goto(`${APP_URL}/?pricing=1`)
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 @@ -2987,7 +2987,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 and demote other owners.",
"promoteConfirm": "Make owner",
"demoteTitle": "Demote {name} to member?",
"demoteMessage": "They'll lose admin access.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,29 +40,18 @@ vi.mock(
)

const mockPermissions = vi.hoisted(() => ({
value: { canManageSubscriptionLifecycle: true }
value: { canManageSubscription: true }
}))

vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({ permissions: mockPermissions })
}))

const mockFetchMembers = vi.hoisted(() => vi.fn().mockResolvedValue([]))

vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
fetchMembers: mockFetchMembers
})
}))

describe('usePricingTableUrlLoader', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRouteQuery.value = {}
mockPermissions.value = { canManageSubscriptionLifecycle: true }
// clearAllMocks resets calls, not implementations, so restore the default
// (a test overrides fetchMembers to flip the gate mid-await).
mockFetchMembers.mockResolvedValue([])
mockPermissions.value = { canManageSubscription: true }
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue(null)
})

Expand All @@ -80,7 +69,7 @@ describe('usePricingTableUrlLoader', () => {
expect(mockRouterReplace).not.toHaveBeenCalled()
})

it('opens the pricing table for an original owner', async () => {
it('opens the pricing table for an owner', async () => {
mockRouteQuery.value = { pricing: '1' }

const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
Expand All @@ -93,22 +82,6 @@ describe('usePricingTableUrlLoader', () => {
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
})

it('reads the gate only after members finish loading', async () => {
mockRouteQuery.value = { pricing: '1' }
// The original owner becomes known only once the members list resolves;
// proves the loader awaits fetchMembers before reading the gate.
mockPermissions.value = { canManageSubscriptionLifecycle: false }
mockFetchMembers.mockImplementation(async () => {
mockPermissions.value = { canManageSubscriptionLifecycle: true }
return []
})

const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
await loadPricingTableFromUrl()

expect(mockShowPricingTable).toHaveBeenCalledOnce()
})

it('opens on the team tab for ?pricing=team', async () => {
mockRouteQuery.value = { pricing: 'team' }

Expand All @@ -133,9 +106,9 @@ describe('usePricingTableUrlLoader', () => {
})
})

it('is a silent no-op for a member or promoted owner', async () => {
it('is a silent no-op for a member', async () => {
mockRouteQuery.value = { pricing: '1' }
mockPermissions.value = { canManageSubscriptionLifecycle: false }
mockPermissions.value = { canManageSubscription: false }

const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
await loadPricingTableFromUrl()
Expand All @@ -145,7 +118,7 @@ describe('usePricingTableUrlLoader', () => {

it('denies, strips, and clears together when the user is not eligible', async () => {
mockRouteQuery.value = { pricing: '1', other: 'param' }
mockPermissions.value = { canManageSubscriptionLifecycle: false }
mockPermissions.value = { canManageSubscription: false }

const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
await loadPricingTableFromUrl()
Expand Down Expand Up @@ -208,20 +181,4 @@ describe('usePricingTableUrlLoader', () => {
planMode: undefined
})
})

it('strips and clears, then propagates a members-fetch failure', async () => {
mockRouteQuery.value = { pricing: '1' }
mockFetchMembers.mockRejectedValue(new Error('listMembers failed'))

const { loadPricingTableFromUrl } = usePricingTableUrlLoader()
await expect(loadPricingTableFromUrl()).rejects.toThrow(
'listMembers failed'
)

expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'pricing'
)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,18 @@ import {
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'

const NAMESPACE = PRESERVED_QUERY_NAMESPACES.PRICING

/**
* Opens the pricing table from a `?pricing=` deep link, to send pilot users
* straight to subscribe. Values: `1` (default tab), `team`, `personal`.
*
* Gated to the original owner (`canManageSubscriptionLifecycle`); a member or
* promoted owner is a silent no-op with the param stripped. Survives the login
* redirect via the preserved-query system, like the invite URL loader.
*/
export function usePricingTableUrlLoader() {
const route = useRoute()
const router = useRouter()
const subscriptionDialog = useSubscriptionDialog()
const workspaceStore = useTeamWorkspaceStore()
const { permissions } = useWorkspaceUI()

/** Reads `?pricing=`, strips it, and opens the table when the gate allows. */
Expand Down Expand Up @@ -52,11 +47,7 @@ export function usePricingTableUrlLoader() {
// gets stripped above.
if (typeof param !== 'string' || !param) return

// Load members before reading the gate so the original-owner self-row is
// present. fetchMembers always awaits the request; ensureMembersLoaded can
// early-return on a cached/in-flight load and let the gate read empty members.
await workspaceStore.fetchMembers()
if (!permissions.value.canManageSubscriptionLifecycle) return
if (!permissions.value.canManageSubscription) return

const planMode =
param === 'team' || param === 'personal' ? param : undefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,7 @@
button-variant="subscribe"
/>
<Button
v-if="
showSubscribeAction &&
!isPersonalWorkspace &&
(!isCancelled || permissions.canManageSubscriptionLifecycle)
"
v-if="showSubscribeAction && !isPersonalWorkspace"
variant="primary"
size="sm"
@click="handleOpenPlansAndPricing"
Expand Down
Loading
Loading