diff --git a/browser_tests/tests/dialogs/memberRoleChange.spec.ts b/browser_tests/tests/dialogs/memberRoleChange.spec.ts index 6768031fe17..6ce43c6d22d 100644 --- a/browser_tests/tests/dialogs/memberRoleChange.spec.ts +++ b/browser_tests/tests/dialogs/memberRoleChange.spec.ts @@ -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' @@ -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) @@ -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. @@ -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() diff --git a/browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts b/browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts index c23cdde6505..1a0c80acd98 100644 --- a/browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts +++ b/browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts @@ -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 @@ -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`) diff --git a/src/locales/en/main.json b/src/locales/en/main.json index e5159c1159a..ceacb4f22e4 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -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.", diff --git a/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts b/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts index c91e2f8efcb..05f6194fec5 100644 --- a/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts +++ b/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.test.ts @@ -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) }) @@ -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() @@ -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' } @@ -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() @@ -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() @@ -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' - ) - }) }) diff --git a/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts b/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts index a94fca4b498..6c02d7472c5 100644 --- a/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts +++ b/src/platform/cloud/subscription/composables/usePricingTableUrlLoader.ts @@ -8,7 +8,6 @@ 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 @@ -16,15 +15,11 @@ 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. */ @@ -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 diff --git a/src/platform/workspace/components/CurrentUserPopoverWorkspace.vue b/src/platform/workspace/components/CurrentUserPopoverWorkspace.vue index a8d209a5eda..e06e02a70c5 100644 --- a/src/platform/workspace/components/CurrentUserPopoverWorkspace.vue +++ b/src/platform/workspace/components/CurrentUserPopoverWorkspace.vue @@ -113,11 +113,7 @@ button-variant="subscribe" />