From 6583e65dca0612b62ddc70f3c68d57b4d5e58a42 Mon Sep 17 00:00:00 2001 From: dante01yoon Date: Sat, 13 Jun 2026 20:20:57 +0900 Subject: [PATCH 01/10] feat(workspace): creator-only canManageSubscriptionLifecycle permission (FE-770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a creator-only subscription-lifecycle permission so cancel / reactivate / downgrade can be gated to the workspace's original owner (any owner keeps canManageSubscription for manage-payment / top-up / change-commit), per the billing-permission matrix in the FE SSOT. ASSUMES /api/workspaces exposes a current-user-relative is_creator flag — BE spec is NOT finalized (field shape + original-owner determination are open, FE-770 Q3 / BE-1337). Fails closed: when is_creator is absent the lifecycle permission is false, so no behavior changes until the BE signal lands. Code comments mark every assumption point for revisit. Consumers (FE-978 cancel/ reactivate, FE-977 downgrade) wire to this once it is available. --- src/platform/workspace/api/workspaceApi.ts | 8 +++++ .../composables/useWorkspaceUI.test.ts | 35 +++++++++++++++++++ .../workspace/composables/useWorkspaceUI.ts | 26 ++++++++++++-- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/platform/workspace/api/workspaceApi.ts b/src/platform/workspace/api/workspaceApi.ts index 05a930e9b8b..2c5cdd0ae41 100644 --- a/src/platform/workspace/api/workspaceApi.ts +++ b/src/platform/workspace/api/workspaceApi.ts @@ -23,6 +23,14 @@ interface Workspace { export interface WorkspaceWithRole extends Workspace { role: WorkspaceRole subscription_tier?: SubscriptionTier + // ASSUMED FIELD — BE SPEC NOT FINALIZED. REVISIT (FE-770 Q3 / BE-1337). + // Presumed current-user-relative flag (like `role`): true when the current + // user is the workspace's original owner/creator. Gates the creator-only + // billing lifecycle actions (cancel / reactivate / downgrade) — see "Roles & + // Billing-Permission Gating" in the FE SSOT. The real field name/shape and the + // determination method (proposed "by creation date") are still open; until BE + // ships this, `is_creator` is undefined and lifecycle gating fails closed. + is_creator?: boolean } export interface Member { diff --git a/src/platform/workspace/composables/useWorkspaceUI.test.ts b/src/platform/workspace/composables/useWorkspaceUI.test.ts index 2c88030e242..9b036500cd0 100644 --- a/src/platform/workspace/composables/useWorkspaceUI.test.ts +++ b/src/platform/workspace/composables/useWorkspaceUI.test.ts @@ -195,6 +195,41 @@ describe('useWorkspaceUI', () => { }) }) + // ASSUMES /api/workspaces exposes `is_creator` — BE spec not finalized. + describe('subscription lifecycle (creator-only)', () => { + it('grants lifecycle to the personal-workspace sole owner', async () => { + mockActiveWorkspace.value = personalWorkspace + const ui = await loadComposable() + expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(true) + }) + + it('grants lifecycle to a team owner flagged as original owner', async () => { + mockActiveWorkspace.value = { ...teamOwnerWorkspace, is_creator: true } + const ui = await loadComposable() + expect(ui.permissions.value.canManageSubscription).toBe(true) + expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(true) + }) + + it('withholds lifecycle from a promoted (non-creator) team owner', async () => { + mockActiveWorkspace.value = { ...teamOwnerWorkspace, is_creator: false } + const ui = await loadComposable() + expect(ui.permissions.value.canManageSubscription).toBe(true) + expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false) + }) + + it('fails closed when is_creator is absent (BE flag not shipped yet)', async () => { + mockActiveWorkspace.value = teamOwnerWorkspace + const ui = await loadComposable() + expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false) + }) + + it('withholds lifecycle from members', async () => { + mockActiveWorkspace.value = teamMemberWorkspace + const ui = await loadComposable() + expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false) + }) + }) + describe('shared instance', () => { it('returns the same composable state for multiple callers within a test', async () => { mockActiveWorkspace.value = teamOwnerWorkspace diff --git a/src/platform/workspace/composables/useWorkspaceUI.ts b/src/platform/workspace/composables/useWorkspaceUI.ts index ab88c7798b1..c804b61b6fc 100644 --- a/src/platform/workspace/composables/useWorkspaceUI.ts +++ b/src/platform/workspace/composables/useWorkspaceUI.ts @@ -14,6 +14,10 @@ interface WorkspacePermissions { canLeaveWorkspace: boolean canAccessWorkspaceMenu: boolean canManageSubscription: boolean + // Creator-only subscription lifecycle: cancel / reactivate / downgrade. + // Any owner has `canManageSubscription` (manage payment, top-up, change + // commit); only the original owner gets `canManageSubscriptionLifecycle`. + canManageSubscriptionLifecycle: boolean canTopUp: boolean } @@ -34,7 +38,8 @@ interface WorkspaceUIConfig { function getPermissions( type: WorkspaceType, - role: WorkspaceRole + role: WorkspaceRole, + isOriginalOwner: boolean ): WorkspacePermissions { if (type === 'personal') { return { @@ -46,6 +51,8 @@ function getPermissions( canLeaveWorkspace: false, canAccessWorkspaceMenu: false, canManageSubscription: true, + // Personal workspace is single-member: the user is the sole owner/creator. + canManageSubscriptionLifecycle: true, canTopUp: true } } @@ -60,6 +67,7 @@ function getPermissions( canLeaveWorkspace: true, canAccessWorkspaceMenu: true, canManageSubscription: true, + canManageSubscriptionLifecycle: isOriginalOwner, canTopUp: true } } @@ -74,6 +82,7 @@ function getPermissions( canLeaveWorkspace: true, canAccessWorkspaceMenu: true, canManageSubscription: false, + canManageSubscriptionLifecycle: false, canTopUp: false } } @@ -145,8 +154,21 @@ function useWorkspaceUIInternal() { () => store.activeWorkspace?.role ?? 'owner' ) + // ASSUMED SIGNAL — BE SPEC NOT FINALIZED. REVISIT (FE-770 Q3 / BE-1337). + // Reads `is_creator` off the active workspace, presumed to come from + // /api/workspaces. Until BE ships it the flag is undefined → fails closed + // (lifecycle actions hidden for everyone in a team). See "Roles & + // Billing-Permission Gating" in the FE SSOT. + const isOriginalOwner = computed( + () => store.activeWorkspace?.is_creator ?? false + ) + const permissions = computed(() => - getPermissions(workspaceType.value, workspaceRole.value) + getPermissions( + workspaceType.value, + workspaceRole.value, + isOriginalOwner.value + ) ) const uiConfig = computed(() => From b4ee092fd3ed174fce8fbf84a531d714a7bd52ae Mon Sep 17 00:00:00 2001 From: dante01yoon Date: Sat, 13 Jun 2026 21:00:12 +0900 Subject: [PATCH 02/10] fix(workspace): pass is_creator through the shared type + auth schema (FE-770) CodeRabbit (#12829): is_creator was declared on api/workspaceApi WorkspaceWithRole but the duplicate type in workspaceTypes.ts and the Zod schema in workspaceAuthStore stripped it, so the flag could be dropped on the auth/session parse path. Align both so the original-owner flag survives. Still ASSUMED / BE spec not finalized (FE-770 Q3 / BE-1337); optional, fails closed. --- src/platform/workspace/stores/workspaceAuthStore.ts | 6 +++++- src/platform/workspace/workspaceTypes.ts | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/platform/workspace/stores/workspaceAuthStore.ts b/src/platform/workspace/stores/workspaceAuthStore.ts index 98665e2f2dc..b135414221c 100644 --- a/src/platform/workspace/stores/workspaceAuthStore.ts +++ b/src/platform/workspace/stores/workspaceAuthStore.ts @@ -18,7 +18,11 @@ const WorkspaceWithRoleSchema = z.object({ id: z.string(), name: z.string(), type: z.enum(['personal', 'team']), - role: z.enum(['owner', 'member']) + role: z.enum(['owner', 'member']), + // ASSUMED FIELD — BE SPEC NOT FINALIZED. REVISIT (FE-770 Q3 / BE-1337). + // Pass the original-owner flag through so it isn't stripped on the + // auth/session parse path. Optional until the BE signal lands. + is_creator: z.boolean().optional() }) const WorkspaceTokenResponseSchema = z.object({ diff --git a/src/platform/workspace/workspaceTypes.ts b/src/platform/workspace/workspaceTypes.ts index 8da24adf02f..d9a5cb9d834 100644 --- a/src/platform/workspace/workspaceTypes.ts +++ b/src/platform/workspace/workspaceTypes.ts @@ -20,4 +20,8 @@ export interface WorkspaceWithRole { name: string type: 'personal' | 'team' role: 'owner' | 'member' + // ASSUMED FIELD — BE SPEC NOT FINALIZED. REVISIT (FE-770 Q3 / BE-1337). + // Mirrors WorkspaceWithRole in api/workspaceApi.ts; kept in sync so the + // original-owner flag survives the auth/session schema parse. + is_creator?: boolean } From 1bc10fa6dcef056511ba7b8be83b4a132f7dfe34 Mon Sep 17 00:00:00 2001 From: dante01yoon Date: Wed, 17 Jun 2026 19:58:33 +0900 Subject: [PATCH 03/10] docs(workspace): mark is_creator shape confirmed by BE (FE-770) BE confirmed the current-user-relative is_creator boolean and that the creator is tracked explicitly (not by creation date). Field stays optional and gating fails closed until it actually ships on /api/workspaces. --- src/platform/workspace/api/workspaceApi.ts | 14 +++++++------- .../workspace/composables/useWorkspaceUI.test.ts | 2 +- .../workspace/composables/useWorkspaceUI.ts | 8 +++----- .../workspace/stores/workspaceAuthStore.ts | 3 +-- src/platform/workspace/workspaceTypes.ts | 4 ++-- 5 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/platform/workspace/api/workspaceApi.ts b/src/platform/workspace/api/workspaceApi.ts index 2c5cdd0ae41..89124f9234c 100644 --- a/src/platform/workspace/api/workspaceApi.ts +++ b/src/platform/workspace/api/workspaceApi.ts @@ -23,13 +23,13 @@ interface Workspace { export interface WorkspaceWithRole extends Workspace { role: WorkspaceRole subscription_tier?: SubscriptionTier - // ASSUMED FIELD — BE SPEC NOT FINALIZED. REVISIT (FE-770 Q3 / BE-1337). - // Presumed current-user-relative flag (like `role`): true when the current - // user is the workspace's original owner/creator. Gates the creator-only - // billing lifecycle actions (cancel / reactivate / downgrade) — see "Roles & - // Billing-Permission Gating" in the FE SSOT. The real field name/shape and the - // determination method (proposed "by creation date") are still open; until BE - // ships this, `is_creator` is undefined and lifecycle gating fails closed. + // Current-user-relative flag (like `role`): true when the requesting user is + // the workspace's original owner/creator. Gates the creator-only billing + // lifecycle actions (cancel / reactivate / downgrade). Shape confirmed by BE + // (boolean, current-user-relative); BE tracks the creator explicitly. Optional + // until the field actually ships on /api/workspaces, so lifecycle gating fails + // closed (hidden for everyone) until then. Temporary gate — drops out once + // member removal auto-provisions a personal workspace. is_creator?: boolean } diff --git a/src/platform/workspace/composables/useWorkspaceUI.test.ts b/src/platform/workspace/composables/useWorkspaceUI.test.ts index 9b036500cd0..0e04745bc4e 100644 --- a/src/platform/workspace/composables/useWorkspaceUI.test.ts +++ b/src/platform/workspace/composables/useWorkspaceUI.test.ts @@ -195,7 +195,7 @@ describe('useWorkspaceUI', () => { }) }) - // ASSUMES /api/workspaces exposes `is_creator` — BE spec not finalized. + // Drives off `is_creator` from /api/workspaces (shape confirmed by BE). describe('subscription lifecycle (creator-only)', () => { it('grants lifecycle to the personal-workspace sole owner', async () => { mockActiveWorkspace.value = personalWorkspace diff --git a/src/platform/workspace/composables/useWorkspaceUI.ts b/src/platform/workspace/composables/useWorkspaceUI.ts index c804b61b6fc..e89cdd83484 100644 --- a/src/platform/workspace/composables/useWorkspaceUI.ts +++ b/src/platform/workspace/composables/useWorkspaceUI.ts @@ -154,11 +154,9 @@ function useWorkspaceUIInternal() { () => store.activeWorkspace?.role ?? 'owner' ) - // ASSUMED SIGNAL — BE SPEC NOT FINALIZED. REVISIT (FE-770 Q3 / BE-1337). - // Reads `is_creator` off the active workspace, presumed to come from - // /api/workspaces. Until BE ships it the flag is undefined → fails closed - // (lifecycle actions hidden for everyone in a team). See "Roles & - // Billing-Permission Gating" in the FE SSOT. + // Reads `is_creator` off the active workspace (from /api/workspaces). Shape + // confirmed by BE; until the field actually ships the flag is undefined → + // fails closed (lifecycle actions hidden for everyone in a team). const isOriginalOwner = computed( () => store.activeWorkspace?.is_creator ?? false ) diff --git a/src/platform/workspace/stores/workspaceAuthStore.ts b/src/platform/workspace/stores/workspaceAuthStore.ts index b135414221c..ec8bd61376e 100644 --- a/src/platform/workspace/stores/workspaceAuthStore.ts +++ b/src/platform/workspace/stores/workspaceAuthStore.ts @@ -19,9 +19,8 @@ const WorkspaceWithRoleSchema = z.object({ name: z.string(), type: z.enum(['personal', 'team']), role: z.enum(['owner', 'member']), - // ASSUMED FIELD — BE SPEC NOT FINALIZED. REVISIT (FE-770 Q3 / BE-1337). // Pass the original-owner flag through so it isn't stripped on the - // auth/session parse path. Optional until the BE signal lands. + // auth/session parse path. Optional until BE ships it. is_creator: z.boolean().optional() }) diff --git a/src/platform/workspace/workspaceTypes.ts b/src/platform/workspace/workspaceTypes.ts index d9a5cb9d834..ef9ca9360ba 100644 --- a/src/platform/workspace/workspaceTypes.ts +++ b/src/platform/workspace/workspaceTypes.ts @@ -20,8 +20,8 @@ export interface WorkspaceWithRole { name: string type: 'personal' | 'team' role: 'owner' | 'member' - // ASSUMED FIELD — BE SPEC NOT FINALIZED. REVISIT (FE-770 Q3 / BE-1337). // Mirrors WorkspaceWithRole in api/workspaceApi.ts; kept in sync so the - // original-owner flag survives the auth/session schema parse. + // original-owner flag survives the auth/session schema parse. Optional until + // BE ships it on /api/workspaces. is_creator?: boolean } From 8c098ee2139e5c355ea373465297c293f72134e2 Mon Sep 17 00:00:00 2001 From: dante01yoon Date: Wed, 17 Jun 2026 22:32:27 +0900 Subject: [PATCH 04/10] docs(workspace): note generated ingest-types swap once is_creator ships (FE-770) WorkspaceWithRole + WorkspaceWithRoleSchema are hand-rolled only because the cloud ingest OpenAPI doesn't expose is_creator yet. @comfyorg/ingest-types already generates the type + zWorkspaceWithRole; swap to them in this PR once the spec ships the field. --- src/platform/workspace/api/workspaceApi.ts | 4 ++++ src/platform/workspace/stores/workspaceAuthStore.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/src/platform/workspace/api/workspaceApi.ts b/src/platform/workspace/api/workspaceApi.ts index 89124f9234c..831f7262aef 100644 --- a/src/platform/workspace/api/workspaceApi.ts +++ b/src/platform/workspace/api/workspaceApi.ts @@ -20,6 +20,10 @@ interface Workspace { joined_at: string } +// TODO(this PR, once the cloud ingest OpenAPI exposes `is_creator`): drop this +// hand-rolled interface and the `WorkspaceWithRoleSchema` Zod in +// workspaceAuthStore for the generated `WorkspaceWithRole` + `zWorkspaceWithRole` +// from `@comfyorg/ingest-types`. Manual only because the spec lacks the field today. export interface WorkspaceWithRole extends Workspace { role: WorkspaceRole subscription_tier?: SubscriptionTier diff --git a/src/platform/workspace/stores/workspaceAuthStore.ts b/src/platform/workspace/stores/workspaceAuthStore.ts index ec8bd61376e..54ad3d86053 100644 --- a/src/platform/workspace/stores/workspaceAuthStore.ts +++ b/src/platform/workspace/stores/workspaceAuthStore.ts @@ -14,6 +14,8 @@ import type { AuthHeader } from '@/types/authTypes' import type { WorkspaceWithRole } from '@/platform/workspace/workspaceTypes' import { useFeatureFlags } from '@/composables/useFeatureFlags' +// TODO(this PR): replace with `zWorkspaceWithRole` from `@comfyorg/ingest-types` +// once the cloud ingest OpenAPI exposes `is_creator` (see workspaceApi.ts). const WorkspaceWithRoleSchema = z.object({ id: z.string(), name: z.string(), From 96ac29b8711639fa163e9ac876f9fbbe90d828cc Mon Sep 17 00:00:00 2001 From: dante01yoon Date: Thu, 18 Jun 2026 09:55:23 +0900 Subject: [PATCH 05/10] docs(workspace): reword ingest-types TODO without PR-scoped phrasing (FE-770) Drop the confusing "this PR" reference per review; the comment outlives the PR, so describe the swap condition (OpenAPI exposes is_creator) as a plain TODO. --- src/platform/workspace/api/workspaceApi.ts | 8 ++++---- src/platform/workspace/stores/workspaceAuthStore.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/platform/workspace/api/workspaceApi.ts b/src/platform/workspace/api/workspaceApi.ts index 831f7262aef..5c8bf52b426 100644 --- a/src/platform/workspace/api/workspaceApi.ts +++ b/src/platform/workspace/api/workspaceApi.ts @@ -20,10 +20,10 @@ interface Workspace { joined_at: string } -// TODO(this PR, once the cloud ingest OpenAPI exposes `is_creator`): drop this -// hand-rolled interface and the `WorkspaceWithRoleSchema` Zod in -// workspaceAuthStore for the generated `WorkspaceWithRole` + `zWorkspaceWithRole` -// from `@comfyorg/ingest-types`. Manual only because the spec lacks the field today. +// TODO: once the cloud ingest OpenAPI exposes `is_creator`, drop this hand-rolled +// interface and the `WorkspaceWithRoleSchema` Zod in workspaceAuthStore for the +// generated `WorkspaceWithRole` + `zWorkspaceWithRole` from `@comfyorg/ingest-types`. +// Hand-rolled only because the spec lacks the field today. export interface WorkspaceWithRole extends Workspace { role: WorkspaceRole subscription_tier?: SubscriptionTier diff --git a/src/platform/workspace/stores/workspaceAuthStore.ts b/src/platform/workspace/stores/workspaceAuthStore.ts index 54ad3d86053..808b656ad36 100644 --- a/src/platform/workspace/stores/workspaceAuthStore.ts +++ b/src/platform/workspace/stores/workspaceAuthStore.ts @@ -14,8 +14,8 @@ import type { AuthHeader } from '@/types/authTypes' import type { WorkspaceWithRole } from '@/platform/workspace/workspaceTypes' import { useFeatureFlags } from '@/composables/useFeatureFlags' -// TODO(this PR): replace with `zWorkspaceWithRole` from `@comfyorg/ingest-types` -// once the cloud ingest OpenAPI exposes `is_creator` (see workspaceApi.ts). +// TODO: replace with `zWorkspaceWithRole` from `@comfyorg/ingest-types` once the +// cloud ingest OpenAPI exposes `is_creator` (see workspaceApi.ts). const WorkspaceWithRoleSchema = z.object({ id: z.string(), name: z.string(), From a51183ae8a94e7bb265a7b03970b8258cb91c6dd Mon Sep 17 00:00:00 2001 From: dante01yoon Date: Thu, 18 Jun 2026 14:58:46 +0900 Subject: [PATCH 06/10] fix(workspace): gate creator lifecycle on members-list original owner (FE-770) --- src/platform/workspace/api/workspaceApi.ts | 16 +-- .../settings/MembersPanelContent.test.ts | 4 +- .../composables/useMembersPanel.test.ts | 1 + .../workspace/composables/useMembersPanel.ts | 3 +- .../composables/useWorkspaceUI.test.ts | 79 ++++++++++--- .../workspace/composables/useWorkspaceUI.ts | 24 +++- .../stores/teamWorkspaceStore.test.ts | 104 ++++++++++++++++++ .../workspace/stores/teamWorkspaceStore.ts | 18 ++- .../workspace/stores/workspaceAuthStore.ts | 7 +- src/platform/workspace/workspaceTypes.ts | 4 - 10 files changed, 211 insertions(+), 49 deletions(-) diff --git a/src/platform/workspace/api/workspaceApi.ts b/src/platform/workspace/api/workspaceApi.ts index 5c8bf52b426..014ff5a2eb5 100644 --- a/src/platform/workspace/api/workspaceApi.ts +++ b/src/platform/workspace/api/workspaceApi.ts @@ -20,21 +20,9 @@ interface Workspace { joined_at: string } -// TODO: once the cloud ingest OpenAPI exposes `is_creator`, drop this hand-rolled -// interface and the `WorkspaceWithRoleSchema` Zod in workspaceAuthStore for the -// generated `WorkspaceWithRole` + `zWorkspaceWithRole` from `@comfyorg/ingest-types`. -// Hand-rolled only because the spec lacks the field today. export interface WorkspaceWithRole extends Workspace { role: WorkspaceRole subscription_tier?: SubscriptionTier - // Current-user-relative flag (like `role`): true when the requesting user is - // the workspace's original owner/creator. Gates the creator-only billing - // lifecycle actions (cancel / reactivate / downgrade). Shape confirmed by BE - // (boolean, current-user-relative); BE tracks the creator explicitly. Optional - // until the field actually ships on /api/workspaces, so lifecycle gating fails - // closed (hidden for everyone) until then. Temporary gate — drops out once - // member removal auto-provisions a personal workspace. - is_creator?: boolean } export interface Member { @@ -43,6 +31,10 @@ export interface Member { email: string joined_at: string role: WorkspaceRole + // True when this member is the workspace's original owner/creator + // (member.id == workspace.created_by_user_id). Gates the creator-only + // billing lifecycle actions (cancel / reactivate / downgrade). + is_original_owner: boolean } interface PaginationInfo { diff --git a/src/platform/workspace/components/dialogs/settings/MembersPanelContent.test.ts b/src/platform/workspace/components/dialogs/settings/MembersPanelContent.test.ts index 9d49cbfd59b..fbaba6cf8d9 100644 --- a/src/platform/workspace/components/dialogs/settings/MembersPanelContent.test.ts +++ b/src/platform/workspace/components/dialogs/settings/MembersPanelContent.test.ts @@ -82,7 +82,8 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({ name: 'Owner User', email: 'owner@example.com', role: 'owner' as const, - joinDate: new Date(0) + joinDate: new Date(0), + isOriginalOwner: true })), filteredMembers: mockFilteredMembers, filteredPendingInvites: mockFilteredPendingInvites, @@ -153,6 +154,7 @@ function createMember( email: 'member1@example.com', joinDate: new Date('2025-01-15'), role: 'member', + isOriginalOwner: false, ...overrides } } diff --git a/src/platform/workspace/composables/useMembersPanel.test.ts b/src/platform/workspace/composables/useMembersPanel.test.ts index 7e9c71b29d6..9686e07c413 100644 --- a/src/platform/workspace/composables/useMembersPanel.test.ts +++ b/src/platform/workspace/composables/useMembersPanel.test.ts @@ -21,6 +21,7 @@ function createMember( email: 'member1@example.com', joinDate: new Date('2025-01-15'), role: 'member', + isOriginalOwner: false, ...overrides } } diff --git a/src/platform/workspace/composables/useMembersPanel.ts b/src/platform/workspace/composables/useMembersPanel.ts index 502c0d699b0..4b14fd6f988 100644 --- a/src/platform/workspace/composables/useMembersPanel.ts +++ b/src/platform/workspace/composables/useMembersPanel.ts @@ -111,7 +111,8 @@ export function useMembersPanel() { name: userDisplayName.value ?? '', email: userEmail.value ?? '', role: 'owner' as const, - joinDate: new Date(0) + joinDate: new Date(0), + isOriginalOwner: true })) const searchQuery = ref('') diff --git a/src/platform/workspace/composables/useWorkspaceUI.test.ts b/src/platform/workspace/composables/useWorkspaceUI.test.ts index 0e04745bc4e..adc177c0f02 100644 --- a/src/platform/workspace/composables/useWorkspaceUI.test.ts +++ b/src/platform/workspace/composables/useWorkspaceUI.test.ts @@ -2,15 +2,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi' -const mockActiveWorkspace = vi.hoisted(() => ({ - value: null as WorkspaceWithRole | null +const mockStore = vi.hoisted(() => ({ + activeWorkspace: null as WorkspaceWithRole | null, + members: [] as unknown[], + isCurrentUserOriginalOwner: false, + fetchMembers: vi.fn() })) vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({ useTeamWorkspaceStore: () => ({ get activeWorkspace() { - return mockActiveWorkspace.value - } + return mockStore.activeWorkspace + }, + get members() { + return mockStore.members + }, + get isCurrentUserOriginalOwner() { + return mockStore.isCurrentUserOriginalOwner + }, + fetchMembers: mockStore.fetchMembers }) })) @@ -46,14 +56,21 @@ async function loadComposable() { return module.useWorkspaceUI() } +function resetStore() { + mockStore.activeWorkspace = null + mockStore.members = [] + mockStore.isCurrentUserOriginalOwner = false + mockStore.fetchMembers.mockReset() +} + describe('useWorkspaceUI', () => { beforeEach(() => { vi.resetModules() - mockActiveWorkspace.value = null + resetStore() }) afterEach(() => { - mockActiveWorkspace.value = null + resetStore() }) describe('when no active workspace', () => { @@ -71,7 +88,7 @@ describe('useWorkspaceUI', () => { describe('personal workspace', () => { beforeEach(() => { - mockActiveWorkspace.value = personalWorkspace + mockStore.activeWorkspace = personalWorkspace }) it('grants billing access but disables team management', async () => { @@ -119,7 +136,7 @@ describe('useWorkspaceUI', () => { describe('team workspace as owner', () => { beforeEach(() => { - mockActiveWorkspace.value = teamOwnerWorkspace + mockStore.activeWorkspace = teamOwnerWorkspace }) it('grants full management permissions', async () => { @@ -159,7 +176,7 @@ describe('useWorkspaceUI', () => { describe('team workspace as member', () => { beforeEach(() => { - mockActiveWorkspace.value = teamMemberWorkspace + mockStore.activeWorkspace = teamMemberWorkspace }) it('restricts management actions while allowing leave', async () => { @@ -195,44 +212,70 @@ describe('useWorkspaceUI', () => { }) }) - // Drives off `is_creator` from /api/workspaces (shape confirmed by BE). + // Drives off the members-list self-row original-owner signal, surfaced by the + // store getter `isCurrentUserOriginalOwner`. describe('subscription lifecycle (creator-only)', () => { it('grants lifecycle to the personal-workspace sole owner', async () => { - mockActiveWorkspace.value = personalWorkspace + mockStore.activeWorkspace = personalWorkspace const ui = await loadComposable() expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(true) }) - it('grants lifecycle to a team owner flagged as original owner', async () => { - mockActiveWorkspace.value = { ...teamOwnerWorkspace, is_creator: true } + it('grants lifecycle to a team owner who is the original owner', async () => { + mockStore.activeWorkspace = teamOwnerWorkspace + mockStore.members = [{ email: 'self@test.com', isOriginalOwner: true }] + mockStore.isCurrentUserOriginalOwner = true const ui = await loadComposable() expect(ui.permissions.value.canManageSubscription).toBe(true) expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(true) }) it('withholds lifecycle from a promoted (non-creator) team owner', async () => { - mockActiveWorkspace.value = { ...teamOwnerWorkspace, is_creator: false } + mockStore.activeWorkspace = teamOwnerWorkspace + mockStore.members = [{ email: 'self@test.com', isOriginalOwner: false }] + mockStore.isCurrentUserOriginalOwner = false const ui = await loadComposable() expect(ui.permissions.value.canManageSubscription).toBe(true) expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false) }) - it('fails closed when is_creator is absent (BE flag not shipped yet)', async () => { - mockActiveWorkspace.value = teamOwnerWorkspace + it('fails closed while the members list is still loading', async () => { + mockStore.activeWorkspace = teamOwnerWorkspace + mockStore.isCurrentUserOriginalOwner = false const ui = await loadComposable() expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false) }) it('withholds lifecycle from members', async () => { - mockActiveWorkspace.value = teamMemberWorkspace + mockStore.activeWorkspace = teamMemberWorkspace const ui = await loadComposable() expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(false) }) + + it('fetches members for a team workspace whose list is not loaded', async () => { + mockStore.activeWorkspace = teamOwnerWorkspace + mockStore.members = [] + await loadComposable() + expect(mockStore.fetchMembers).toHaveBeenCalledTimes(1) + }) + + it('does not refetch when members are already loaded', async () => { + mockStore.activeWorkspace = teamOwnerWorkspace + mockStore.members = [{ email: 'self@test.com', isOriginalOwner: true }] + await loadComposable() + expect(mockStore.fetchMembers).not.toHaveBeenCalled() + }) + + it('does not fetch members for a personal workspace', async () => { + mockStore.activeWorkspace = personalWorkspace + await loadComposable() + expect(mockStore.fetchMembers).not.toHaveBeenCalled() + }) }) describe('shared instance', () => { it('returns the same composable state for multiple callers within a test', async () => { - mockActiveWorkspace.value = teamOwnerWorkspace + mockStore.activeWorkspace = teamOwnerWorkspace const first = await loadComposable() const second = await loadComposable() diff --git a/src/platform/workspace/composables/useWorkspaceUI.ts b/src/platform/workspace/composables/useWorkspaceUI.ts index e89cdd83484..5f2baf18b6f 100644 --- a/src/platform/workspace/composables/useWorkspaceUI.ts +++ b/src/platform/workspace/composables/useWorkspaceUI.ts @@ -1,4 +1,4 @@ -import { computed } from 'vue' +import { computed, watch } from 'vue' import { createSharedComposable } from '@vueuse/core' import type { WorkspaceRole, WorkspaceType } from '../api/workspaceApi' @@ -154,13 +154,25 @@ function useWorkspaceUIInternal() { () => store.activeWorkspace?.role ?? 'owner' ) - // Reads `is_creator` off the active workspace (from /api/workspaces). Shape - // confirmed by BE; until the field actually ships the flag is undefined → - // fails closed (lifecycle actions hidden for everyone in a team). - const isOriginalOwner = computed( - () => store.activeWorkspace?.is_creator ?? false + // The original-owner signal lives on the members-list self-row, so the + // members list must be loaded before the team gate resolves. Trigger a fetch + // for team workspaces whose members aren't loaded yet; until they arrive the + // store getter fails closed, hiding lifecycle actions during the load window. + // Watch the workspace id (not the object) so a fetch-induced identity change + // can't retrigger the watch; an empty members response then can't loop. + watch( + () => store.activeWorkspace?.id, + () => { + const workspace = store.activeWorkspace + if (workspace?.type === 'team' && store.members.length === 0) { + void store.fetchMembers() + } + }, + { immediate: true } ) + const isOriginalOwner = computed(() => store.isCurrentUserOriginalOwner) + const permissions = computed(() => getPermissions( workspaceType.value, diff --git a/src/platform/workspace/stores/teamWorkspaceStore.test.ts b/src/platform/workspace/stores/teamWorkspaceStore.test.ts index 0baf59288eb..7df17a57ea6 100644 --- a/src/platform/workspace/stores/teamWorkspaceStore.test.ts +++ b/src/platform/workspace/stores/teamWorkspaceStore.test.ts @@ -29,6 +29,15 @@ vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({ useWorkspaceAuthStore: () => mockWorkspaceAuthStore })) +// Mock current user (drives the original-owner self-row match by email) +const mockCurrentUser = vi.hoisted(() => ({ + userEmail: { value: null as string | null } +})) + +vi.mock('@/composables/auth/useCurrentUser', () => ({ + useCurrentUser: () => ({ userEmail: mockCurrentUser.userEmail }) +})) + // Mock workspaceApi const mockWorkspaceApi = vi.hoisted(() => ({ list: vi.fn(), @@ -122,6 +131,7 @@ describe('useTeamWorkspaceStore', () => { vi.clearAllMocks() vi.stubGlobal('localStorage', mockLocalStorage) sessionStorage.clear() + mockCurrentUser.userEmail.value = null // Reset workspaceAuthStore mock state mockWorkspaceAuthStore.currentWorkspace = null @@ -680,6 +690,100 @@ describe('useTeamWorkspaceStore', () => { }) }) + describe('isCurrentUserOriginalOwner', () => { + async function loadTeamWithMembers( + members: Array<{ + id: string + name: string + email: string + joined_at: string + is_original_owner: boolean + }> + ) { + mockWorkspaceApi.listMembers.mockResolvedValue({ + members, + pagination: { offset: 0, limit: 50, total: members.length } + }) + mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true) + mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace + + const store = useTeamWorkspaceStore() + await store.initialize() + await store.fetchMembers() + return store + } + + const ownerSelf = { + id: 'user-1', + name: 'Owner', + email: 'owner@test.com', + joined_at: '2024-01-01T00:00:00Z', + role: 'owner' as const, + is_original_owner: true + } + const promotedSelf = { ...ownerSelf, is_original_owner: false } + + it('is true when the self-row is the original owner', async () => { + mockCurrentUser.userEmail.value = 'owner@test.com' + const store = await loadTeamWithMembers([ownerSelf]) + expect(store.isCurrentUserOriginalOwner).toBe(true) + }) + + it('matches the self-row by email case-insensitively', async () => { + mockCurrentUser.userEmail.value = 'OWNER@TEST.COM' + const store = await loadTeamWithMembers([ownerSelf]) + expect(store.isCurrentUserOriginalOwner).toBe(true) + }) + + it('is false when the self-row is a promoted (non-creator) owner', async () => { + mockCurrentUser.userEmail.value = 'owner@test.com' + const store = await loadTeamWithMembers([promotedSelf]) + expect(store.isCurrentUserOriginalOwner).toBe(false) + }) + + it('is false when no member row matches the current user', async () => { + mockCurrentUser.userEmail.value = 'someone-else@test.com' + const store = await loadTeamWithMembers([ownerSelf]) + expect(store.isCurrentUserOriginalOwner).toBe(false) + }) + + it('fails closed when members are not loaded', async () => { + mockCurrentUser.userEmail.value = 'owner@test.com' + mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true) + mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace + + const store = useTeamWorkspaceStore() + await store.initialize() + + expect(store.isCurrentUserOriginalOwner).toBe(false) + }) + + it('fails closed when the current user email is unknown', async () => { + mockCurrentUser.userEmail.value = null + const store = await loadTeamWithMembers([ownerSelf]) + expect(store.isCurrentUserOriginalOwner).toBe(false) + }) + + it('recomputes reactively when the self-row arrives after an empty read', async () => { + mockCurrentUser.userEmail.value = 'owner@test.com' + mockWorkspaceApi.listMembers.mockResolvedValue({ + members: [ownerSelf], + pagination: { offset: 0, limit: 50, total: 1 } + }) + mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true) + mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace + + const store = useTeamWorkspaceStore() + await store.initialize() + + expect(store.isCurrentUserOriginalOwner).toBe(false) + + await store.fetchMembers() + + expect(store.isCurrentUserOriginalOwner).toBe(true) + }) + }) + describe('invite actions', () => { it('fetchPendingInvites updates active workspace invites', async () => { const mockInvites = [ diff --git a/src/platform/workspace/stores/teamWorkspaceStore.ts b/src/platform/workspace/stores/teamWorkspaceStore.ts index 27e4e1ee6c2..26d00e490bb 100644 --- a/src/platform/workspace/stores/teamWorkspaceStore.ts +++ b/src/platform/workspace/stores/teamWorkspaceStore.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { computed, ref, shallowRef } from 'vue' +import { useCurrentUser } from '@/composables/auth/useCurrentUser' import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants' import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager' import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces' @@ -21,6 +22,7 @@ export interface WorkspaceMember { email: string joinDate: Date role: 'owner' | 'member' + isOriginalOwner: boolean } export interface PendingInvite { @@ -49,7 +51,8 @@ function mapApiMemberToWorkspaceMember(member: Member): WorkspaceMember { name: member.name, email: member.email, joinDate: new Date(member.joined_at), - role: member.role + role: member.role, + isOriginalOwner: member.is_original_owner } } @@ -146,6 +149,18 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => { () => activeWorkspace.value?.members ?? [] ) + // True when the current user is the active workspace's original owner, + // resolved from the self-row of the loaded members list. Matches by email + // (the stable current-user join key; member.id is a cloud user id, not the + // Firebase uid). Fails closed when members are not loaded or no self-row + // matches, so lifecycle gating stays hidden until the real signal arrives. + const isCurrentUserOriginalOwner = computed(() => { + const email = useCurrentUser().userEmail.value?.toLowerCase() + if (!email) return false + const selfRow = members.value.find((m) => m.email.toLowerCase() === email) + return selfRow?.isOriginalOwner ?? false + }) + const pendingInvites = computed( () => activeWorkspace.value?.pendingInvites ?? [] ) @@ -652,6 +667,7 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => { ownedWorkspacesCount, canCreateWorkspace, members, + isCurrentUserOriginalOwner, pendingInvites, totalMemberSlots, isInviteLimitReached, diff --git a/src/platform/workspace/stores/workspaceAuthStore.ts b/src/platform/workspace/stores/workspaceAuthStore.ts index 808b656ad36..98665e2f2dc 100644 --- a/src/platform/workspace/stores/workspaceAuthStore.ts +++ b/src/platform/workspace/stores/workspaceAuthStore.ts @@ -14,16 +14,11 @@ import type { AuthHeader } from '@/types/authTypes' import type { WorkspaceWithRole } from '@/platform/workspace/workspaceTypes' import { useFeatureFlags } from '@/composables/useFeatureFlags' -// TODO: replace with `zWorkspaceWithRole` from `@comfyorg/ingest-types` once the -// cloud ingest OpenAPI exposes `is_creator` (see workspaceApi.ts). const WorkspaceWithRoleSchema = z.object({ id: z.string(), name: z.string(), type: z.enum(['personal', 'team']), - role: z.enum(['owner', 'member']), - // Pass the original-owner flag through so it isn't stripped on the - // auth/session parse path. Optional until BE ships it. - is_creator: z.boolean().optional() + role: z.enum(['owner', 'member']) }) const WorkspaceTokenResponseSchema = z.object({ diff --git a/src/platform/workspace/workspaceTypes.ts b/src/platform/workspace/workspaceTypes.ts index ef9ca9360ba..8da24adf02f 100644 --- a/src/platform/workspace/workspaceTypes.ts +++ b/src/platform/workspace/workspaceTypes.ts @@ -20,8 +20,4 @@ export interface WorkspaceWithRole { name: string type: 'personal' | 'team' role: 'owner' | 'member' - // Mirrors WorkspaceWithRole in api/workspaceApi.ts; kept in sync so the - // original-owner flag survives the auth/session schema parse. Optional until - // BE ships it on /api/workspaces. - is_creator?: boolean } From 0092a45983f14a36c56fdbaf5c8f69ac852d5d2c Mon Sep 17 00:00:00 2001 From: dante01yoon Date: Thu, 18 Jun 2026 19:16:02 +0900 Subject: [PATCH 07/10] fix(workspace): type is_original_owner as optional to match the BE contract (FE-770) --- src/platform/workspace/api/workspaceApi.ts | 3 ++- src/platform/workspace/stores/teamWorkspaceStore.test.ts | 9 ++++++++- src/platform/workspace/stores/teamWorkspaceStore.ts | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/platform/workspace/api/workspaceApi.ts b/src/platform/workspace/api/workspaceApi.ts index 014ff5a2eb5..3aa53aa571d 100644 --- a/src/platform/workspace/api/workspaceApi.ts +++ b/src/platform/workspace/api/workspaceApi.ts @@ -34,7 +34,8 @@ export interface Member { // True when this member is the workspace's original owner/creator // (member.id == workspace.created_by_user_id). Gates the creator-only // billing lifecycle actions (cancel / reactivate / downgrade). - is_original_owner: boolean + // Optional: the cloud OpenAPI does not carry this field yet. + is_original_owner?: boolean } interface PaginationInfo { diff --git a/src/platform/workspace/stores/teamWorkspaceStore.test.ts b/src/platform/workspace/stores/teamWorkspaceStore.test.ts index 7df17a57ea6..99098ea641d 100644 --- a/src/platform/workspace/stores/teamWorkspaceStore.test.ts +++ b/src/platform/workspace/stores/teamWorkspaceStore.test.ts @@ -697,7 +697,7 @@ describe('useTeamWorkspaceStore', () => { name: string email: string joined_at: string - is_original_owner: boolean + is_original_owner?: boolean }> ) { mockWorkspaceApi.listMembers.mockResolvedValue({ @@ -741,6 +741,13 @@ describe('useTeamWorkspaceStore', () => { expect(store.isCurrentUserOriginalOwner).toBe(false) }) + it('fails closed when the self-row omits is_original_owner', async () => { + mockCurrentUser.userEmail.value = 'owner@test.com' + const { is_original_owner: _omitted, ...selfWithoutFlag } = ownerSelf + const store = await loadTeamWithMembers([selfWithoutFlag]) + expect(store.isCurrentUserOriginalOwner).toBe(false) + }) + it('is false when no member row matches the current user', async () => { mockCurrentUser.userEmail.value = 'someone-else@test.com' const store = await loadTeamWithMembers([ownerSelf]) diff --git a/src/platform/workspace/stores/teamWorkspaceStore.ts b/src/platform/workspace/stores/teamWorkspaceStore.ts index 26d00e490bb..271a0c6642a 100644 --- a/src/platform/workspace/stores/teamWorkspaceStore.ts +++ b/src/platform/workspace/stores/teamWorkspaceStore.ts @@ -52,7 +52,7 @@ function mapApiMemberToWorkspaceMember(member: Member): WorkspaceMember { email: member.email, joinDate: new Date(member.joined_at), role: member.role, - isOriginalOwner: member.is_original_owner + isOriginalOwner: member.is_original_owner ?? false } } From 2d109b233de9bf950e0f812bb4bfcfee13977fc9 Mon Sep 17 00:00:00 2001 From: Dante Date: Fri, 19 Jun 2026 09:37:00 +0900 Subject: [PATCH 08/10] feat(billing): gate cancel/reactivate to the original owner (FE-978) (#12830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on the permission infra #12829. Gates owner **Cancel** (plan menu) + **Resubscribe** (panel button + workspace popover re-activate path) on `canManageSubscriptionLifecycle` (original owner only), per the billing-permission matrix (FE SSOT). Promoted owners keep manage-payment / upgrade / top-up; members get none. **Original-owner signal (repointed).** Rebased onto the updated #12829 (`a51183ae`), so the gate now resolves from the member-list `is_original_owner` field that the cloud cutover (Comfy-Org/cloud #4359) ships: the store getter `isCurrentUserOriginalOwner` matches the current user's member self-row by email, and `useWorkspaceUI` eagerly `fetchMembers()` so billing surfaces have the roster loaded. This replaces the earlier `is_creator`-on-`/api/workspaces` assumption — BE standardized on a per-member `is_original_owner` instead. No `is_creator` remains. **Merge-gated** on cutover cloud#4359 reaching cloud main: until `GET /api/workspace/members` returns `is_original_owner` in prod, the gate fails closed (lifecycle actions hidden for every owner). Fail-closed = no regression pre-deploy, but do not merge until the field is live. **Follow-up (non-blocking).** Exposing `is_original_owner` (current-user-relative, sibling of `role`) on `/api/workspaces` — Hunter pre-approved 2026-06-17 — would let us read it directly and drop the eager member fetch. Tracked on #12829 / FE-770. Part of FE-978 (member run-lock modal shipped separately in #12786). --- .../CurrentUserPopoverWorkspace.vue | 6 +++- .../SubscriptionPanelContentWorkspace.vue | 31 ++++++++++++------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/platform/workspace/components/CurrentUserPopoverWorkspace.vue b/src/platform/workspace/components/CurrentUserPopoverWorkspace.vue index 27425ba4ac0..dc6b796fd1f 100644 --- a/src/platform/workspace/components/CurrentUserPopoverWorkspace.vue +++ b/src/platform/workspace/components/CurrentUserPopoverWorkspace.vue @@ -112,7 +112,11 @@ button-variant="gradient" />