Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
8 changes: 8 additions & 0 deletions src/platform/workspace/api/workspaceApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ interface Workspace {
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
5 changes: 4 additions & 1 deletion src/platform/workspace/stores/workspaceAuthStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ 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