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"
/>