Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
12 changes: 12 additions & 0 deletions src/platform/workspace/api/workspaceApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,21 @@ 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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export interface Member {
Expand Down
35 changes: 35 additions & 0 deletions src/platform/workspace/composables/useWorkspaceUI.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,41 @@ describe('useWorkspaceUI', () => {
})
})

// 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
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
Expand Down
24 changes: 22 additions & 2 deletions src/platform/workspace/composables/useWorkspaceUI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -34,7 +38,8 @@ interface WorkspaceUIConfig {

function getPermissions(
type: WorkspaceType,
role: WorkspaceRole
role: WorkspaceRole,
isOriginalOwner: boolean
): WorkspacePermissions {
if (type === 'personal') {
return {
Expand All @@ -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
}
}
Expand All @@ -60,6 +67,7 @@ function getPermissions(
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true,
canManageSubscriptionLifecycle: isOriginalOwner,
canTopUp: true
}
}
Expand All @@ -74,6 +82,7 @@ function getPermissions(
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: false,
canManageSubscriptionLifecycle: false,
canTopUp: false
}
}
Expand Down Expand Up @@ -145,8 +154,19 @@ 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
)

const permissions = computed<WorkspacePermissions>(() =>
getPermissions(workspaceType.value, workspaceRole.value)
getPermissions(
workspaceType.value,
workspaceRole.value,
isOriginalOwner.value
)
)

const uiConfig = computed<WorkspaceUIConfig>(() =>
Expand Down
7 changes: 6 additions & 1 deletion src/platform/workspace/stores/workspaceAuthStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,16 @@ 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'])
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()
})

const WorkspaceTokenResponseSchema = z.object({
Expand Down
4 changes: 4 additions & 0 deletions src/platform/workspace/workspaceTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,8 @@ 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
}
Loading