Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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(this PR, once the cloud ingest OpenAPI exposes `is_creator`): drop this

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO "this PR" is a bit confusing here

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworded in 96ac29b — dropped the this PR reference; it's now a plain forward TODO keyed on the cloud ingest OpenAPI exposing is_creator. The generated-type swap is tracked as a follow-up (PR description), since this one is safe to merge ahead of BE.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworded in 96ac29b to drop the PR-scoped "this PR" phrasing — the TODO now just points at swapping the hand-rolled type/schema for the generated WorkspaceWithRole/zWorkspaceWithRole once BE adds is_original_owner to the ingest OpenAPI.

// 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
// 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(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(),
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