Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
4 changes: 4 additions & 0 deletions src/platform/workspace/api/workspaceApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -153,6 +154,7 @@ function createMember(
email: 'member1@example.com',
joinDate: new Date('2025-01-15'),
role: 'member',
isOriginalOwner: false,
...overrides
}
}
Expand Down
1 change: 1 addition & 0 deletions src/platform/workspace/composables/useMembersPanel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function createMember(
email: 'member1@example.com',
joinDate: new Date('2025-01-15'),
role: 'member',
isOriginalOwner: false,
...overrides
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/platform/workspace/composables/useMembersPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('')
Expand Down
98 changes: 88 additions & 10 deletions src/platform/workspace/composables/useWorkspaceUI.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
}))

Expand Down Expand Up @@ -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', () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -119,7 +136,7 @@ describe('useWorkspaceUI', () => {

describe('team workspace as owner', () => {
beforeEach(() => {
mockActiveWorkspace.value = teamOwnerWorkspace
mockStore.activeWorkspace = teamOwnerWorkspace
})

it('grants full management permissions', async () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -195,9 +212,70 @@ describe('useWorkspaceUI', () => {
})
})

// 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 () => {
mockStore.activeWorkspace = personalWorkspace
const ui = await loadComposable()
expect(ui.permissions.value.canManageSubscriptionLifecycle).toBe(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 () => {
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 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 () => {
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()

Expand Down
38 changes: 35 additions & 3 deletions src/platform/workspace/composables/useWorkspaceUI.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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,31 @@ function useWorkspaceUIInternal() {
() => store.activeWorkspace?.role ?? 'owner'
)

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

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.

This may be better off living in the store as an "ensureMembersLoaded", guarded for in flight duplicate calls, catch the failed request and log if failed, and with a "membersLoaded" flag instead of the .length === 0 check

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.

Moved into the store as ensureMembersLoaded in 8278c9d: no-ops for personal/already-loaded workspaces, dedupes in-flight calls, and catches+logs failures (leaving the workspace unloaded so a later call retries). Used a per-workspace loaded Set rather than a single membersLoaded boolean — a global flag would skip loading the new team on workspace switch.

}
},
{ immediate: true }
)

const isOriginalOwner = computed(() => store.isCurrentUserOriginalOwner)

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.

Looks redundant, can just use store.isCurrentUserOriginalOwner

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.

Done in 8278c9d — inlined store.isCurrentUserOriginalOwner directly into the permissions computed.


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

const uiConfig = computed<WorkspaceUIConfig>(() =>
Expand Down
Loading
Loading