mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 10:42:44 +00:00
feat(workspace): add store and composables with tests
- Add teamWorkspaceStore with state management and token refresh - Add useWorkspaceUI composable for role-based permissions - Add useInviteUrlLoader for invite URL handling - Add useWorkspaceSwitch hook - Include comprehensive test coverage (1400+ lines)
This commit is contained in:
@@ -1,22 +1,22 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
|
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
|
||||||
import type { WorkspaceWithRole } from '@/platform/auth/workspace/workspaceTypes'
|
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
|
||||||
|
|
||||||
const mockSwitchWorkspace = vi.hoisted(() => vi.fn())
|
const mockSwitchWorkspace = vi.hoisted(() => vi.fn())
|
||||||
const mockCurrentWorkspace = vi.hoisted(() => ({
|
const mockActiveWorkspace = vi.hoisted(() => ({
|
||||||
value: null as WorkspaceWithRole | null
|
value: null as WorkspaceWithRole | null
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/stores/workspaceAuthStore', () => ({
|
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||||
useWorkspaceAuthStore: () => ({
|
useTeamWorkspaceStore: () => ({
|
||||||
switchWorkspace: mockSwitchWorkspace
|
switchWorkspace: mockSwitchWorkspace
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('pinia', () => ({
|
vi.mock('pinia', () => ({
|
||||||
storeToRefs: () => ({
|
storeToRefs: () => ({
|
||||||
currentWorkspace: mockCurrentWorkspace
|
activeWorkspace: mockActiveWorkspace
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -46,19 +46,16 @@ vi.mock('vue-i18n', () => ({
|
|||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const mockReload = vi.fn()
|
|
||||||
|
|
||||||
describe('useWorkspaceSwitch', () => {
|
describe('useWorkspaceSwitch', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockCurrentWorkspace.value = {
|
mockActiveWorkspace.value = {
|
||||||
id: 'workspace-1',
|
id: 'workspace-1',
|
||||||
name: 'Test Workspace',
|
name: 'Test Workspace',
|
||||||
type: 'personal',
|
type: 'personal',
|
||||||
role: 'owner'
|
role: 'owner'
|
||||||
}
|
}
|
||||||
mockModifiedWorkflows.length = 0
|
mockModifiedWorkflows.length = 0
|
||||||
vi.stubGlobal('location', { reload: mockReload })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -109,7 +106,6 @@ describe('useWorkspaceSwitch', () => {
|
|||||||
expect(result).toBe(true)
|
expect(result).toBe(true)
|
||||||
expect(mockConfirm).not.toHaveBeenCalled()
|
expect(mockConfirm).not.toHaveBeenCalled()
|
||||||
expect(mockSwitchWorkspace).toHaveBeenCalledWith('workspace-2')
|
expect(mockSwitchWorkspace).toHaveBeenCalledWith('workspace-2')
|
||||||
expect(mockReload).toHaveBeenCalled()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows confirmation dialog when there are unsaved changes', async () => {
|
it('shows confirmation dialog when there are unsaved changes', async () => {
|
||||||
@@ -136,10 +132,9 @@ describe('useWorkspaceSwitch', () => {
|
|||||||
|
|
||||||
expect(result).toBe(false)
|
expect(result).toBe(false)
|
||||||
expect(mockSwitchWorkspace).not.toHaveBeenCalled()
|
expect(mockSwitchWorkspace).not.toHaveBeenCalled()
|
||||||
expect(mockReload).not.toHaveBeenCalled()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls switchWorkspace and reloads page after user confirms', async () => {
|
it('calls switchWorkspace after user confirms', async () => {
|
||||||
mockModifiedWorkflows.push({ isModified: true })
|
mockModifiedWorkflows.push({ isModified: true })
|
||||||
mockConfirm.mockResolvedValue(true)
|
mockConfirm.mockResolvedValue(true)
|
||||||
mockSwitchWorkspace.mockResolvedValue(undefined)
|
mockSwitchWorkspace.mockResolvedValue(undefined)
|
||||||
@@ -149,7 +144,6 @@ describe('useWorkspaceSwitch', () => {
|
|||||||
|
|
||||||
expect(result).toBe(true)
|
expect(result).toBe(true)
|
||||||
expect(mockSwitchWorkspace).toHaveBeenCalledWith('workspace-2')
|
expect(mockSwitchWorkspace).toHaveBeenCalledWith('workspace-2')
|
||||||
expect(mockReload).toHaveBeenCalled()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns false if switchWorkspace throws an error', async () => {
|
it('returns false if switchWorkspace throws an error', async () => {
|
||||||
@@ -160,7 +154,6 @@ describe('useWorkspaceSwitch', () => {
|
|||||||
const result = await switchWithConfirmation('workspace-2')
|
const result = await switchWithConfirmation('workspace-2')
|
||||||
|
|
||||||
expect(result).toBe(false)
|
expect(result).toBe(false)
|
||||||
expect(mockReload).not.toHaveBeenCalled()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { storeToRefs } from 'pinia'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||||
|
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||||
import { useDialogService } from '@/services/dialogService'
|
import { useDialogService } from '@/services/dialogService'
|
||||||
import { useWorkspaceAuthStore } from '@/stores/workspaceAuthStore'
|
|
||||||
|
|
||||||
export function useWorkspaceSwitch() {
|
export function useWorkspaceSwitch() {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const workspaceAuthStore = useWorkspaceAuthStore()
|
const workspaceStore = useTeamWorkspaceStore()
|
||||||
const { currentWorkspace } = storeToRefs(workspaceAuthStore)
|
const { activeWorkspace } = storeToRefs(workspaceStore)
|
||||||
const workflowStore = useWorkflowStore()
|
const workflowStore = useWorkflowStore()
|
||||||
const dialogService = useDialogService()
|
const dialogService = useDialogService()
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ export function useWorkspaceSwitch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function switchWithConfirmation(workspaceId: string): Promise<boolean> {
|
async function switchWithConfirmation(workspaceId: string): Promise<boolean> {
|
||||||
if (currentWorkspace.value?.id === workspaceId) {
|
if (activeWorkspace.value?.id === workspaceId) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,8 +34,8 @@ export function useWorkspaceSwitch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await workspaceAuthStore.switchWorkspace(workspaceId)
|
await workspaceStore.switchWorkspace(workspaceId)
|
||||||
window.location.reload()
|
// Note: switchWorkspace triggers page reload internally
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
|
|||||||
232
src/platform/workspace/composables/useInviteUrlLoader.test.ts
Normal file
232
src/platform/workspace/composables/useInviteUrlLoader.test.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { useInviteUrlLoader } from './useInviteUrlLoader'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for useInviteUrlLoader composable
|
||||||
|
*
|
||||||
|
* Tests the behavior of accepting workspace invites via URL query parameters:
|
||||||
|
* - ?invite=TOKEN accepts the invite and shows success toast
|
||||||
|
* - Invalid/missing token is handled gracefully
|
||||||
|
* - API errors show error toast
|
||||||
|
* - URL is cleaned up after processing
|
||||||
|
* - Preserved query is restored after login redirect
|
||||||
|
*/
|
||||||
|
|
||||||
|
const preservedQueryMocks = vi.hoisted(() => ({
|
||||||
|
clearPreservedQuery: vi.fn(),
|
||||||
|
hydratePreservedQuery: vi.fn(),
|
||||||
|
mergePreservedQueryIntoQuery: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock(
|
||||||
|
'@/platform/navigation/preservedQueryManager',
|
||||||
|
() => preservedQueryMocks
|
||||||
|
)
|
||||||
|
|
||||||
|
const mockRouteQuery = vi.hoisted(() => ({
|
||||||
|
value: {} as Record<string, string>
|
||||||
|
}))
|
||||||
|
const mockRouterReplace = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({
|
||||||
|
useRoute: () => ({
|
||||||
|
query: mockRouteQuery.value
|
||||||
|
}),
|
||||||
|
useRouter: () => ({
|
||||||
|
replace: mockRouterReplace
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockToastAdd = vi.hoisted(() => vi.fn())
|
||||||
|
vi.mock('primevue/usetoast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
add: mockToastAdd
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
createI18n: () => ({
|
||||||
|
global: {
|
||||||
|
t: (key: string) => key
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
useI18n: () => ({
|
||||||
|
t: vi.fn((key: string, params?: Record<string, unknown>) => {
|
||||||
|
if (key === 'workspace.inviteAccepted') return 'Invite Accepted'
|
||||||
|
if (key === 'workspace.addedToWorkspace') {
|
||||||
|
return `You have been added to ${params?.workspaceName}`
|
||||||
|
}
|
||||||
|
if (key === 'workspace.inviteFailed') return 'Failed to Accept Invite'
|
||||||
|
if (key === 'g.unknownError') return 'Unknown error'
|
||||||
|
return key
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockAcceptInvite = vi.hoisted(() => vi.fn())
|
||||||
|
vi.mock('../stores/teamWorkspaceStore', () => ({
|
||||||
|
useTeamWorkspaceStore: () => ({
|
||||||
|
acceptInvite: mockAcceptInvite
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useInviteUrlLoader', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockRouteQuery.value = {}
|
||||||
|
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loadInviteFromUrl', () => {
|
||||||
|
it('does nothing when no invite param present', async () => {
|
||||||
|
mockRouteQuery.value = {}
|
||||||
|
|
||||||
|
const { loadInviteFromUrl } = useInviteUrlLoader()
|
||||||
|
await loadInviteFromUrl()
|
||||||
|
|
||||||
|
expect(mockAcceptInvite).not.toHaveBeenCalled()
|
||||||
|
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||||
|
expect(mockRouterReplace).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores preserved query and processes invite', async () => {
|
||||||
|
mockRouteQuery.value = {}
|
||||||
|
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue({
|
||||||
|
invite: 'preserved-token'
|
||||||
|
})
|
||||||
|
mockAcceptInvite.mockResolvedValue({
|
||||||
|
workspaceId: 'ws-123',
|
||||||
|
workspaceName: 'Test Workspace'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { loadInviteFromUrl } = useInviteUrlLoader()
|
||||||
|
await loadInviteFromUrl()
|
||||||
|
|
||||||
|
expect(preservedQueryMocks.hydratePreservedQuery).toHaveBeenCalledWith(
|
||||||
|
'invite'
|
||||||
|
)
|
||||||
|
expect(mockRouterReplace).toHaveBeenCalledWith({
|
||||||
|
query: { invite: 'preserved-token' }
|
||||||
|
})
|
||||||
|
expect(mockAcceptInvite).toHaveBeenCalledWith('preserved-token')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts invite and shows success toast on success', async () => {
|
||||||
|
mockRouteQuery.value = { invite: 'valid-token' }
|
||||||
|
mockAcceptInvite.mockResolvedValue({
|
||||||
|
workspaceId: 'ws-123',
|
||||||
|
workspaceName: 'Test Workspace'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { loadInviteFromUrl } = useInviteUrlLoader()
|
||||||
|
await loadInviteFromUrl()
|
||||||
|
|
||||||
|
expect(mockAcceptInvite).toHaveBeenCalledWith('valid-token')
|
||||||
|
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Invite Accepted',
|
||||||
|
detail: 'You have been added to Test Workspace',
|
||||||
|
life: 5000
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error toast when invite acceptance fails', async () => {
|
||||||
|
mockRouteQuery.value = { invite: 'invalid-token' }
|
||||||
|
mockAcceptInvite.mockRejectedValue(new Error('Invalid invite'))
|
||||||
|
|
||||||
|
const { loadInviteFromUrl } = useInviteUrlLoader()
|
||||||
|
await loadInviteFromUrl()
|
||||||
|
|
||||||
|
expect(mockAcceptInvite).toHaveBeenCalledWith('invalid-token')
|
||||||
|
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Failed to Accept Invite',
|
||||||
|
detail: 'Invalid invite',
|
||||||
|
life: 5000
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cleans up URL after processing invite', async () => {
|
||||||
|
mockRouteQuery.value = { invite: 'valid-token', other: 'param' }
|
||||||
|
mockAcceptInvite.mockResolvedValue({
|
||||||
|
workspaceId: 'ws-123',
|
||||||
|
workspaceName: 'Test Workspace'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { loadInviteFromUrl } = useInviteUrlLoader()
|
||||||
|
await loadInviteFromUrl()
|
||||||
|
|
||||||
|
// Should replace with query without invite param
|
||||||
|
expect(mockRouterReplace).toHaveBeenCalledWith({
|
||||||
|
query: { other: 'param' }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears preserved query after processing', async () => {
|
||||||
|
mockRouteQuery.value = { invite: 'valid-token' }
|
||||||
|
mockAcceptInvite.mockResolvedValue({
|
||||||
|
workspaceId: 'ws-123',
|
||||||
|
workspaceName: 'Test Workspace'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { loadInviteFromUrl } = useInviteUrlLoader()
|
||||||
|
await loadInviteFromUrl()
|
||||||
|
|
||||||
|
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||||
|
'invite'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears preserved query even on error', async () => {
|
||||||
|
mockRouteQuery.value = { invite: 'invalid-token' }
|
||||||
|
mockAcceptInvite.mockRejectedValue(new Error('Invalid invite'))
|
||||||
|
|
||||||
|
const { loadInviteFromUrl } = useInviteUrlLoader()
|
||||||
|
await loadInviteFromUrl()
|
||||||
|
|
||||||
|
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||||
|
'invite'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends any token format to backend for validation', async () => {
|
||||||
|
mockRouteQuery.value = { invite: 'any-token-format==' }
|
||||||
|
mockAcceptInvite.mockRejectedValue(new Error('Invalid token'))
|
||||||
|
|
||||||
|
const { loadInviteFromUrl } = useInviteUrlLoader()
|
||||||
|
await loadInviteFromUrl()
|
||||||
|
|
||||||
|
// Token is sent to backend, which validates and rejects
|
||||||
|
expect(mockAcceptInvite).toHaveBeenCalledWith('any-token-format==')
|
||||||
|
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Failed to Accept Invite',
|
||||||
|
detail: 'Invalid token',
|
||||||
|
life: 5000
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores empty invite param', async () => {
|
||||||
|
mockRouteQuery.value = { invite: '' }
|
||||||
|
|
||||||
|
const { loadInviteFromUrl } = useInviteUrlLoader()
|
||||||
|
await loadInviteFromUrl()
|
||||||
|
|
||||||
|
expect(mockAcceptInvite).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores non-string invite param', async () => {
|
||||||
|
mockRouteQuery.value = { invite: ['array', 'value'] as unknown as string }
|
||||||
|
|
||||||
|
const { loadInviteFromUrl } = useInviteUrlLoader()
|
||||||
|
await loadInviteFromUrl()
|
||||||
|
|
||||||
|
expect(mockAcceptInvite).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
106
src/platform/workspace/composables/useInviteUrlLoader.ts
Normal file
106
src/platform/workspace/composables/useInviteUrlLoader.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearPreservedQuery,
|
||||||
|
hydratePreservedQuery,
|
||||||
|
mergePreservedQueryIntoQuery
|
||||||
|
} from '@/platform/navigation/preservedQueryManager'
|
||||||
|
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||||
|
|
||||||
|
import { useTeamWorkspaceStore } from '../stores/teamWorkspaceStore'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for loading workspace invites from URL query parameters
|
||||||
|
*
|
||||||
|
* Supports URLs like:
|
||||||
|
* - /?invite=TOKEN (accepts workspace invite)
|
||||||
|
*
|
||||||
|
* The invite token is preserved through login redirects via the
|
||||||
|
* preserved query system (sessionStorage), following the same pattern
|
||||||
|
* as the template URL loader.
|
||||||
|
*/
|
||||||
|
export function useInviteUrlLoader() {
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const toast = useToast()
|
||||||
|
const workspaceStore = useTeamWorkspaceStore()
|
||||||
|
const INVITE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.INVITE
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrates preserved query from sessionStorage and merges into route.
|
||||||
|
* This restores the invite token after login redirects.
|
||||||
|
*/
|
||||||
|
const ensureInviteQueryFromIntent = async () => {
|
||||||
|
hydratePreservedQuery(INVITE_NAMESPACE)
|
||||||
|
const mergedQuery = mergePreservedQueryIntoQuery(
|
||||||
|
INVITE_NAMESPACE,
|
||||||
|
route.query
|
||||||
|
)
|
||||||
|
|
||||||
|
if (mergedQuery) {
|
||||||
|
await router.replace({ query: mergedQuery })
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergedQuery ?? route.query
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes invite parameter from URL using Vue Router
|
||||||
|
*/
|
||||||
|
const cleanupUrlParams = () => {
|
||||||
|
const newQuery = { ...route.query }
|
||||||
|
delete newQuery.invite
|
||||||
|
void router.replace({ query: newQuery })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads and accepts workspace invite from URL query parameters if present.
|
||||||
|
* Handles errors internally and shows appropriate user feedback.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Restore preserved query (for post-login redirect)
|
||||||
|
* 2. Check for invite token in route.query
|
||||||
|
* 3. Accept the invite via API (backend validates token)
|
||||||
|
* 4. Show toast notification
|
||||||
|
* 5. Clean up URL and preserved query
|
||||||
|
*/
|
||||||
|
const loadInviteFromUrl = async () => {
|
||||||
|
// Restore preserved query from sessionStorage (handles login redirect case)
|
||||||
|
const query = await ensureInviteQueryFromIntent()
|
||||||
|
|
||||||
|
const inviteParam = query.invite
|
||||||
|
if (!inviteParam || typeof inviteParam !== 'string') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await workspaceStore.acceptInvite(inviteParam)
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: t('workspace.inviteAccepted'),
|
||||||
|
detail: t('workspace.addedToWorkspace', {
|
||||||
|
workspaceName: result.workspaceName
|
||||||
|
}),
|
||||||
|
life: 5000
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: t('workspace.inviteFailed'),
|
||||||
|
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||||
|
life: 5000
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
cleanupUrlParams()
|
||||||
|
clearPreservedQuery(INVITE_NAMESPACE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loadInviteFromUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
177
src/platform/workspace/composables/useWorkspaceUI.ts
Normal file
177
src/platform/workspace/composables/useWorkspaceUI.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { createSharedComposable } from '@vueuse/core'
|
||||||
|
|
||||||
|
import type { WorkspaceRole, WorkspaceType } from '../api/workspaceApi'
|
||||||
|
import { useTeamWorkspaceStore } from '../stores/teamWorkspaceStore'
|
||||||
|
|
||||||
|
/** Permission flags for workspace actions */
|
||||||
|
interface WorkspacePermissions {
|
||||||
|
canViewOtherMembers: boolean
|
||||||
|
canViewPendingInvites: boolean
|
||||||
|
canInviteMembers: boolean
|
||||||
|
canManageInvites: boolean
|
||||||
|
canRemoveMembers: boolean
|
||||||
|
canLeaveWorkspace: boolean
|
||||||
|
canAccessWorkspaceMenu: boolean
|
||||||
|
canManageSubscription: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UI configuration for workspace role */
|
||||||
|
interface WorkspaceUIConfig {
|
||||||
|
showMembersList: boolean
|
||||||
|
showPendingTab: boolean
|
||||||
|
showSearch: boolean
|
||||||
|
showDateColumn: boolean
|
||||||
|
showRoleBadge: boolean
|
||||||
|
membersGridCols: string
|
||||||
|
pendingGridCols: string
|
||||||
|
headerGridCols: string
|
||||||
|
showEditWorkspaceMenuItem: boolean
|
||||||
|
workspaceMenuAction: 'leave' | 'delete' | null
|
||||||
|
workspaceMenuDisabledTooltip: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPermissions(
|
||||||
|
type: WorkspaceType,
|
||||||
|
role: WorkspaceRole
|
||||||
|
): WorkspacePermissions {
|
||||||
|
if (type === 'personal') {
|
||||||
|
return {
|
||||||
|
canViewOtherMembers: false,
|
||||||
|
canViewPendingInvites: false,
|
||||||
|
canInviteMembers: false,
|
||||||
|
canManageInvites: false,
|
||||||
|
canRemoveMembers: false,
|
||||||
|
canLeaveWorkspace: false,
|
||||||
|
canAccessWorkspaceMenu: true,
|
||||||
|
canManageSubscription: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'owner') {
|
||||||
|
return {
|
||||||
|
canViewOtherMembers: true,
|
||||||
|
canViewPendingInvites: true,
|
||||||
|
canInviteMembers: true,
|
||||||
|
canManageInvites: true,
|
||||||
|
canRemoveMembers: true,
|
||||||
|
canLeaveWorkspace: true,
|
||||||
|
canAccessWorkspaceMenu: true,
|
||||||
|
canManageSubscription: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// member role
|
||||||
|
return {
|
||||||
|
canViewOtherMembers: true,
|
||||||
|
canViewPendingInvites: false,
|
||||||
|
canInviteMembers: false,
|
||||||
|
canManageInvites: false,
|
||||||
|
canRemoveMembers: false,
|
||||||
|
canLeaveWorkspace: true,
|
||||||
|
canAccessWorkspaceMenu: true,
|
||||||
|
canManageSubscription: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUIConfig(
|
||||||
|
type: WorkspaceType,
|
||||||
|
role: WorkspaceRole
|
||||||
|
): WorkspaceUIConfig {
|
||||||
|
if (type === 'personal') {
|
||||||
|
return {
|
||||||
|
showMembersList: false,
|
||||||
|
showPendingTab: false,
|
||||||
|
showSearch: false,
|
||||||
|
showDateColumn: false,
|
||||||
|
showRoleBadge: false,
|
||||||
|
membersGridCols: 'grid-cols-1',
|
||||||
|
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
|
||||||
|
headerGridCols: 'grid-cols-1',
|
||||||
|
showEditWorkspaceMenuItem: true,
|
||||||
|
workspaceMenuAction: null,
|
||||||
|
workspaceMenuDisabledTooltip: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'owner') {
|
||||||
|
return {
|
||||||
|
showMembersList: true,
|
||||||
|
showPendingTab: true,
|
||||||
|
showSearch: true,
|
||||||
|
showDateColumn: true,
|
||||||
|
showRoleBadge: true,
|
||||||
|
membersGridCols: 'grid-cols-[50%_40%_10%]',
|
||||||
|
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
|
||||||
|
headerGridCols: 'grid-cols-[50%_40%_10%]',
|
||||||
|
showEditWorkspaceMenuItem: true,
|
||||||
|
workspaceMenuAction: 'delete',
|
||||||
|
workspaceMenuDisabledTooltip:
|
||||||
|
'workspacePanel.menu.deleteWorkspaceDisabledTooltip'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// member role
|
||||||
|
return {
|
||||||
|
showMembersList: true,
|
||||||
|
showPendingTab: false,
|
||||||
|
showSearch: true,
|
||||||
|
showDateColumn: true,
|
||||||
|
showRoleBadge: true,
|
||||||
|
membersGridCols: 'grid-cols-[1fr_auto]',
|
||||||
|
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
|
||||||
|
headerGridCols: 'grid-cols-[1fr_auto]',
|
||||||
|
showEditWorkspaceMenuItem: false,
|
||||||
|
workspaceMenuAction: 'leave',
|
||||||
|
workspaceMenuDisabledTooltip: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal implementation of UI configuration composable.
|
||||||
|
*/
|
||||||
|
function useWorkspaceUIInternal() {
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
|
||||||
|
// Tab management (shared UI state)
|
||||||
|
const activeTab = ref<string>('plan')
|
||||||
|
|
||||||
|
function setActiveTab(tab: string | number) {
|
||||||
|
activeTab.value = String(tab)
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceType = computed<WorkspaceType>(
|
||||||
|
() => store.activeWorkspace?.type ?? 'personal'
|
||||||
|
)
|
||||||
|
|
||||||
|
const workspaceRole = computed<WorkspaceRole>(
|
||||||
|
() => store.activeWorkspace?.role ?? 'owner'
|
||||||
|
)
|
||||||
|
|
||||||
|
const permissions = computed<WorkspacePermissions>(() =>
|
||||||
|
getPermissions(workspaceType.value, workspaceRole.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
const uiConfig = computed<WorkspaceUIConfig>(() =>
|
||||||
|
getUIConfig(workspaceType.value, workspaceRole.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Tab management
|
||||||
|
activeTab: computed(() => activeTab.value),
|
||||||
|
setActiveTab,
|
||||||
|
|
||||||
|
// Permissions and config
|
||||||
|
permissions,
|
||||||
|
uiConfig,
|
||||||
|
workspaceType,
|
||||||
|
workspaceRole
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI configuration composable derived from workspace state.
|
||||||
|
* Controls what UI elements are visible/enabled based on role and workspace type.
|
||||||
|
* Uses createSharedComposable to ensure tab state is shared across components.
|
||||||
|
*/
|
||||||
|
export const useWorkspaceUI = createSharedComposable(useWorkspaceUIInternal)
|
||||||
1069
src/platform/workspace/stores/teamWorkspaceStore.test.ts
Normal file
1069
src/platform/workspace/stores/teamWorkspaceStore.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
780
src/platform/workspace/stores/teamWorkspaceStore.ts
Normal file
780
src/platform/workspace/stores/teamWorkspaceStore.ts
Normal file
@@ -0,0 +1,780 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed, ref, shallowRef } from 'vue'
|
||||||
|
|
||||||
|
import { TOKEN_REFRESH_BUFFER_MS } from '@/platform/auth/workspace/workspaceConstants'
|
||||||
|
|
||||||
|
import { sessionManager } from '../services/sessionManager'
|
||||||
|
import type {
|
||||||
|
ExchangeTokenResponse,
|
||||||
|
ListMembersParams,
|
||||||
|
Member,
|
||||||
|
PendingInvite as ApiPendingInvite,
|
||||||
|
WorkspaceWithRole
|
||||||
|
} from '../api/workspaceApi'
|
||||||
|
import { workspaceApi, WorkspaceApiError } from '../api/workspaceApi'
|
||||||
|
|
||||||
|
// Extended member type for UI (adds joinDate as Date)
|
||||||
|
export interface WorkspaceMember {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
joinDate: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extended invite type for UI (adds dates as Date objects)
|
||||||
|
export interface PendingInvite {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
token: string
|
||||||
|
inviteDate: Date
|
||||||
|
expiryDate: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubscriptionPlan = 'PRO_MONTHLY' | 'PRO_YEARLY' | null
|
||||||
|
|
||||||
|
interface WorkspaceState extends WorkspaceWithRole {
|
||||||
|
isSubscribed: boolean
|
||||||
|
subscriptionPlan: SubscriptionPlan
|
||||||
|
members: WorkspaceMember[]
|
||||||
|
pendingInvites: PendingInvite[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type InitState = 'uninitialized' | 'loading' | 'ready' | 'error'
|
||||||
|
|
||||||
|
function mapApiMemberToWorkspaceMember(member: Member): WorkspaceMember {
|
||||||
|
return {
|
||||||
|
id: member.id,
|
||||||
|
name: member.name,
|
||||||
|
email: member.email,
|
||||||
|
joinDate: new Date(member.joined_at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapApiInviteToPendingInvite(invite: ApiPendingInvite): PendingInvite {
|
||||||
|
return {
|
||||||
|
id: invite.id,
|
||||||
|
email: invite.email,
|
||||||
|
token: invite.token,
|
||||||
|
inviteDate: new Date(invite.invited_at),
|
||||||
|
expiryDate: new Date(invite.expires_at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWorkspaceState(workspace: WorkspaceWithRole): WorkspaceState {
|
||||||
|
return {
|
||||||
|
...workspace,
|
||||||
|
isSubscribed: false,
|
||||||
|
subscriptionPlan: null,
|
||||||
|
members: [],
|
||||||
|
pendingInvites: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workspace limits
|
||||||
|
const MAX_OWNED_WORKSPACES = 10
|
||||||
|
const MAX_WORKSPACE_MEMBERS = 50
|
||||||
|
|
||||||
|
export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// STATE
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const initState = ref<InitState>('uninitialized')
|
||||||
|
const workspaces = shallowRef<WorkspaceState[]>([])
|
||||||
|
const activeWorkspaceId = ref<string | null>(null)
|
||||||
|
const error = ref<Error | null>(null)
|
||||||
|
|
||||||
|
// Loading states for UI
|
||||||
|
const isCreating = ref(false)
|
||||||
|
const isDeleting = ref(false)
|
||||||
|
const isSwitching = ref(false)
|
||||||
|
const isFetchingWorkspaces = ref(false)
|
||||||
|
|
||||||
|
// Token refresh timer state
|
||||||
|
let refreshTimerId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
// Request ID to prevent stale refresh operations from overwriting newer workspace contexts
|
||||||
|
let tokenRefreshRequestId = 0
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// COMPUTED
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const activeWorkspace = computed(
|
||||||
|
() => workspaces.value.find((w) => w.id === activeWorkspaceId.value) ?? null
|
||||||
|
)
|
||||||
|
|
||||||
|
const personalWorkspace = computed(
|
||||||
|
() => workspaces.value.find((w) => w.type === 'personal') ?? null
|
||||||
|
)
|
||||||
|
|
||||||
|
const isInPersonalWorkspace = computed(
|
||||||
|
() => activeWorkspace.value?.type === 'personal'
|
||||||
|
)
|
||||||
|
|
||||||
|
const sharedWorkspaces = computed(() =>
|
||||||
|
workspaces.value.filter((w) => w.type !== 'personal')
|
||||||
|
)
|
||||||
|
|
||||||
|
const ownedWorkspacesCount = computed(
|
||||||
|
() => workspaces.value.filter((w) => w.role === 'owner').length
|
||||||
|
)
|
||||||
|
|
||||||
|
const canCreateWorkspace = computed(
|
||||||
|
() => ownedWorkspacesCount.value < MAX_OWNED_WORKSPACES
|
||||||
|
)
|
||||||
|
|
||||||
|
const members = computed<WorkspaceMember[]>(
|
||||||
|
() => activeWorkspace.value?.members ?? []
|
||||||
|
)
|
||||||
|
|
||||||
|
const pendingInvites = computed<PendingInvite[]>(
|
||||||
|
() => activeWorkspace.value?.pendingInvites ?? []
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalMemberSlots = computed(
|
||||||
|
() => members.value.length + pendingInvites.value.length
|
||||||
|
)
|
||||||
|
|
||||||
|
const isInviteLimitReached = computed(
|
||||||
|
() => totalMemberSlots.value >= MAX_WORKSPACE_MEMBERS
|
||||||
|
)
|
||||||
|
|
||||||
|
const workspaceId = computed(() => activeWorkspace.value?.id ?? null)
|
||||||
|
|
||||||
|
const workspaceName = computed(() => activeWorkspace.value?.name ?? '')
|
||||||
|
|
||||||
|
const isWorkspaceSubscribed = computed(
|
||||||
|
() => activeWorkspace.value?.isSubscribed ?? false
|
||||||
|
)
|
||||||
|
|
||||||
|
const subscriptionPlan = computed(
|
||||||
|
() => activeWorkspace.value?.subscriptionPlan ?? null
|
||||||
|
)
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// INTERNAL HELPERS
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function updateWorkspace(
|
||||||
|
workspaceId: string,
|
||||||
|
updates: Partial<WorkspaceState>
|
||||||
|
) {
|
||||||
|
const index = workspaces.value.findIndex((w) => w.id === workspaceId)
|
||||||
|
if (index === -1) return
|
||||||
|
|
||||||
|
const current = workspaces.value[index]
|
||||||
|
const updated = { ...current, ...updates }
|
||||||
|
workspaces.value = [
|
||||||
|
...workspaces.value.slice(0, index),
|
||||||
|
updated,
|
||||||
|
...workspaces.value.slice(index + 1)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateActiveWorkspace(updates: Partial<WorkspaceState>) {
|
||||||
|
if (!activeWorkspaceId.value) return
|
||||||
|
updateWorkspace(activeWorkspaceId.value, updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// TOKEN MANAGEMENT
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function stopRefreshTimer(): void {
|
||||||
|
if (refreshTimerId !== null) {
|
||||||
|
clearTimeout(refreshTimerId)
|
||||||
|
refreshTimerId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleTokenRefresh(expiresAt: number): void {
|
||||||
|
stopRefreshTimer()
|
||||||
|
const now = Date.now()
|
||||||
|
const refreshAt = expiresAt - TOKEN_REFRESH_BUFFER_MS
|
||||||
|
const delay = Math.max(0, refreshAt - now)
|
||||||
|
|
||||||
|
refreshTimerId = setTimeout(() => {
|
||||||
|
void refreshWorkspaceToken()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange Firebase token for workspace-scoped token.
|
||||||
|
* Stores the token in sessionStorage and schedules refresh.
|
||||||
|
*/
|
||||||
|
async function exchangeAndStoreToken(
|
||||||
|
workspaceId: string
|
||||||
|
): Promise<ExchangeTokenResponse> {
|
||||||
|
const response = await workspaceApi.exchangeToken(workspaceId)
|
||||||
|
const expiresAt = new Date(response.expires_at).getTime()
|
||||||
|
|
||||||
|
if (isNaN(expiresAt)) {
|
||||||
|
throw new Error('Invalid token expiry timestamp from server')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store token in sessionStorage
|
||||||
|
sessionManager.setWorkspaceToken(response.token, expiresAt)
|
||||||
|
|
||||||
|
// Schedule refresh before expiry
|
||||||
|
scheduleTokenRefresh(expiresAt)
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the workspace token.
|
||||||
|
* Called automatically before token expires.
|
||||||
|
* Includes retry logic for transient failures.
|
||||||
|
*/
|
||||||
|
async function refreshWorkspaceToken(): Promise<void> {
|
||||||
|
if (!activeWorkspaceId.value) return
|
||||||
|
|
||||||
|
const workspaceId = activeWorkspaceId.value
|
||||||
|
const capturedRequestId = tokenRefreshRequestId
|
||||||
|
const maxRetries = 3
|
||||||
|
const baseDelayMs = 1000
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
// Check if workspace context changed during refresh
|
||||||
|
if (capturedRequestId !== tokenRefreshRequestId) {
|
||||||
|
console.warn(
|
||||||
|
'[workspaceStore] Aborting stale token refresh: workspace context changed'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await exchangeAndStoreToken(workspaceId)
|
||||||
|
return
|
||||||
|
} catch (err) {
|
||||||
|
const isApiError = err instanceof WorkspaceApiError
|
||||||
|
|
||||||
|
// Permanent errors - don't retry
|
||||||
|
const isPermanentError =
|
||||||
|
isApiError &&
|
||||||
|
(err.code === 'ACCESS_DENIED' ||
|
||||||
|
err.code === 'WORKSPACE_NOT_FOUND' ||
|
||||||
|
err.code === 'INVALID_FIREBASE_TOKEN' ||
|
||||||
|
err.code === 'NOT_AUTHENTICATED')
|
||||||
|
|
||||||
|
if (isPermanentError) {
|
||||||
|
if (capturedRequestId === tokenRefreshRequestId) {
|
||||||
|
console.error(
|
||||||
|
'[workspaceStore] Workspace access revoked or auth invalid:',
|
||||||
|
err
|
||||||
|
)
|
||||||
|
clearTokenContext()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transient errors - retry with backoff
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
const delay = baseDelayMs * Math.pow(2, attempt)
|
||||||
|
console.warn(
|
||||||
|
`[workspaceStore] Token refresh failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms:`,
|
||||||
|
err
|
||||||
|
)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// All retries exhausted
|
||||||
|
if (capturedRequestId === tokenRefreshRequestId) {
|
||||||
|
console.error(
|
||||||
|
'[workspaceStore] Failed to refresh token after retries:',
|
||||||
|
err
|
||||||
|
)
|
||||||
|
clearTokenContext()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear token context (on auth failure or workspace switch).
|
||||||
|
*/
|
||||||
|
function clearTokenContext(): void {
|
||||||
|
tokenRefreshRequestId++
|
||||||
|
stopRefreshTimer()
|
||||||
|
sessionManager.clearWorkspaceToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we have a valid token in sessionStorage (for page refresh).
|
||||||
|
* If valid, schedule refresh timer. If expired, return false.
|
||||||
|
*/
|
||||||
|
function initializeTokenFromSession(): boolean {
|
||||||
|
const tokenData = sessionManager.getWorkspaceToken()
|
||||||
|
if (!tokenData) return false
|
||||||
|
|
||||||
|
const { expiresAt } = tokenData
|
||||||
|
if (Date.now() >= expiresAt) {
|
||||||
|
// Token expired, clear it
|
||||||
|
sessionManager.clearWorkspaceToken()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token still valid, schedule refresh
|
||||||
|
scheduleTokenRefresh(expiresAt)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// INITIALIZATION
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the workspace store.
|
||||||
|
* Fetches workspaces and resolves the active workspace from session/localStorage.
|
||||||
|
* Call once on app boot.
|
||||||
|
*/
|
||||||
|
async function initialize(): Promise<void> {
|
||||||
|
if (initState.value !== 'uninitialized') return
|
||||||
|
|
||||||
|
initState.value = 'loading'
|
||||||
|
isFetchingWorkspaces.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Fetch all workspaces
|
||||||
|
const response = await workspaceApi.list()
|
||||||
|
workspaces.value = response.workspaces.map(createWorkspaceState)
|
||||||
|
|
||||||
|
if (workspaces.value.length === 0) {
|
||||||
|
throw new Error('No workspaces available')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Determine active workspace (priority: sessionStorage > localStorage > personal)
|
||||||
|
let targetWorkspaceId: string | null = null
|
||||||
|
|
||||||
|
// Try sessionStorage first (page refresh)
|
||||||
|
const sessionId = sessionManager.getCurrentWorkspaceId()
|
||||||
|
if (sessionId && workspaces.value.some((w) => w.id === sessionId)) {
|
||||||
|
targetWorkspaceId = sessionId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try localStorage (cross-session persistence)
|
||||||
|
if (!targetWorkspaceId) {
|
||||||
|
const lastId = sessionManager.getLastWorkspaceId()
|
||||||
|
if (lastId && workspaces.value.some((w) => w.id === lastId)) {
|
||||||
|
targetWorkspaceId = lastId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to personal workspace
|
||||||
|
if (!targetWorkspaceId) {
|
||||||
|
const personal = workspaces.value.find((w) => w.type === 'personal')
|
||||||
|
targetWorkspaceId = personal?.id ?? workspaces.value[0].id
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Set active workspace
|
||||||
|
activeWorkspaceId.value = targetWorkspaceId
|
||||||
|
sessionManager.setCurrentWorkspaceId(targetWorkspaceId)
|
||||||
|
sessionManager.setLastWorkspaceId(targetWorkspaceId)
|
||||||
|
|
||||||
|
// 4. Initialize workspace token
|
||||||
|
// First check if we have a valid token from session (page refresh case)
|
||||||
|
const hasValidToken = initializeTokenFromSession()
|
||||||
|
|
||||||
|
if (!hasValidToken) {
|
||||||
|
// No valid token - exchange Firebase token for workspace token
|
||||||
|
try {
|
||||||
|
await exchangeAndStoreToken(targetWorkspaceId)
|
||||||
|
} catch (tokenError) {
|
||||||
|
// Log but don't fail initialization - API calls will fall back to Firebase token
|
||||||
|
console.error(
|
||||||
|
'[workspaceStore] Token exchange failed during init:',
|
||||||
|
tokenError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initState.value = 'ready'
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e : new Error('Unknown error')
|
||||||
|
initState.value = 'error'
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
isFetchingWorkspaces.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-fetch workspaces from API without changing active workspace.
|
||||||
|
*/
|
||||||
|
async function refreshWorkspaces(): Promise<void> {
|
||||||
|
isFetchingWorkspaces.value = true
|
||||||
|
try {
|
||||||
|
const response = await workspaceApi.list()
|
||||||
|
workspaces.value = response.workspaces.map(createWorkspaceState)
|
||||||
|
} finally {
|
||||||
|
isFetchingWorkspaces.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// WORKSPACE ACTIONS
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to a different workspace.
|
||||||
|
* Sets session storage and reloads the page.
|
||||||
|
*/
|
||||||
|
async function switchWorkspace(workspaceId: string): Promise<void> {
|
||||||
|
if (workspaceId === activeWorkspaceId.value) return
|
||||||
|
|
||||||
|
// Invalidate any in-flight token refresh for the old workspace
|
||||||
|
clearTokenContext()
|
||||||
|
|
||||||
|
isSwitching.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify workspace exists in our list (user has access)
|
||||||
|
const workspace = workspaces.value.find((w) => w.id === workspaceId)
|
||||||
|
if (!workspace) {
|
||||||
|
// Workspace not in list - try refetching in case it was added
|
||||||
|
await refreshWorkspaces()
|
||||||
|
const refreshedWorkspace = workspaces.value.find(
|
||||||
|
(w) => w.id === workspaceId
|
||||||
|
)
|
||||||
|
if (!refreshedWorkspace) {
|
||||||
|
throw new Error('Workspace not found or access denied')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success - switch and reload
|
||||||
|
sessionManager.switchWorkspaceAndReload(workspaceId)
|
||||||
|
// Code after this won't run (page reloads)
|
||||||
|
} catch (e) {
|
||||||
|
isSwitching.value = false
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new workspace and switch to it.
|
||||||
|
*/
|
||||||
|
async function createWorkspace(name: string): Promise<WorkspaceState> {
|
||||||
|
isCreating.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newWorkspace = await workspaceApi.create({ name })
|
||||||
|
const workspaceState = createWorkspaceState(newWorkspace)
|
||||||
|
|
||||||
|
// Add to local list
|
||||||
|
workspaces.value = [...workspaces.value, workspaceState]
|
||||||
|
|
||||||
|
// Switch to new workspace (triggers reload)
|
||||||
|
sessionManager.switchWorkspaceAndReload(newWorkspace.id)
|
||||||
|
|
||||||
|
// Code after this won't run (page reloads)
|
||||||
|
return workspaceState
|
||||||
|
} catch (e) {
|
||||||
|
isCreating.value = false
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a workspace.
|
||||||
|
* If deleting active workspace, switches to personal.
|
||||||
|
*/
|
||||||
|
async function deleteWorkspace(workspaceId?: string): Promise<void> {
|
||||||
|
const targetId = workspaceId ?? activeWorkspaceId.value
|
||||||
|
if (!targetId) throw new Error('No workspace to delete')
|
||||||
|
|
||||||
|
const workspace = workspaces.value.find((w) => w.id === targetId)
|
||||||
|
if (!workspace) throw new Error('Workspace not found')
|
||||||
|
if (workspace.type === 'personal') {
|
||||||
|
throw new Error('Cannot delete personal workspace')
|
||||||
|
}
|
||||||
|
|
||||||
|
isDeleting.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await workspaceApi.delete(targetId)
|
||||||
|
|
||||||
|
if (targetId === activeWorkspaceId.value) {
|
||||||
|
// Deleted active workspace - go to personal
|
||||||
|
const personal = personalWorkspace.value
|
||||||
|
if (personal) {
|
||||||
|
sessionManager.switchWorkspaceAndReload(personal.id)
|
||||||
|
} else {
|
||||||
|
sessionManager.clearAndReload()
|
||||||
|
}
|
||||||
|
// Code after this won't run (page reloads)
|
||||||
|
} else {
|
||||||
|
// Deleted non-active workspace - just update local list
|
||||||
|
workspaces.value = workspaces.value.filter((w) => w.id !== targetId)
|
||||||
|
isDeleting.value = false
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
isDeleting.value = false
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a workspace. No reload needed.
|
||||||
|
*/
|
||||||
|
async function renameWorkspace(
|
||||||
|
workspaceId: string,
|
||||||
|
newName: string
|
||||||
|
): Promise<void> {
|
||||||
|
const updated = await workspaceApi.update(workspaceId, { name: newName })
|
||||||
|
updateWorkspace(workspaceId, { name: updated.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update workspace name (convenience for current workspace).
|
||||||
|
*/
|
||||||
|
async function updateWorkspaceName(name: string): Promise<void> {
|
||||||
|
if (!activeWorkspaceId.value) {
|
||||||
|
throw new Error('No active workspace')
|
||||||
|
}
|
||||||
|
await renameWorkspace(activeWorkspaceId.value, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leave the current workspace.
|
||||||
|
* Switches to personal workspace after leaving.
|
||||||
|
*/
|
||||||
|
async function leaveWorkspace(): Promise<void> {
|
||||||
|
const current = activeWorkspace.value
|
||||||
|
if (!current || current.type === 'personal') {
|
||||||
|
throw new Error('Cannot leave personal workspace')
|
||||||
|
}
|
||||||
|
|
||||||
|
await workspaceApi.leave()
|
||||||
|
|
||||||
|
// Go to personal workspace
|
||||||
|
const personal = personalWorkspace.value
|
||||||
|
if (personal) {
|
||||||
|
sessionManager.switchWorkspaceAndReload(personal.id)
|
||||||
|
} else {
|
||||||
|
sessionManager.clearAndReload()
|
||||||
|
}
|
||||||
|
// Code after this won't run (page reloads)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// MEMBER ACTIONS
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch members for the current workspace.
|
||||||
|
*/
|
||||||
|
async function fetchMembers(
|
||||||
|
params?: ListMembersParams
|
||||||
|
): Promise<WorkspaceMember[]> {
|
||||||
|
if (!activeWorkspaceId.value) return []
|
||||||
|
if (activeWorkspace.value?.type === 'personal') return []
|
||||||
|
|
||||||
|
const response = await workspaceApi.listMembers(params)
|
||||||
|
const members = response.members.map(mapApiMemberToWorkspaceMember)
|
||||||
|
updateActiveWorkspace({ members })
|
||||||
|
return members
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a member from the current workspace.
|
||||||
|
*/
|
||||||
|
async function removeMember(userId: string): Promise<void> {
|
||||||
|
await workspaceApi.removeMember(userId)
|
||||||
|
const current = activeWorkspace.value
|
||||||
|
if (current) {
|
||||||
|
updateActiveWorkspace({
|
||||||
|
members: current.members.filter((m) => m.id !== userId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// INVITE ACTIONS
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch pending invites for the current workspace.
|
||||||
|
*/
|
||||||
|
async function fetchPendingInvites(): Promise<PendingInvite[]> {
|
||||||
|
if (!activeWorkspaceId.value) return []
|
||||||
|
if (activeWorkspace.value?.type === 'personal') return []
|
||||||
|
|
||||||
|
const response = await workspaceApi.listInvites()
|
||||||
|
const invites = response.invites.map(mapApiInviteToPendingInvite)
|
||||||
|
updateActiveWorkspace({ pendingInvites: invites })
|
||||||
|
return invites
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an invite for the current workspace.
|
||||||
|
*/
|
||||||
|
async function createInvite(email: string): Promise<PendingInvite> {
|
||||||
|
const response = await workspaceApi.createInvite({ email })
|
||||||
|
const invite = mapApiInviteToPendingInvite(response)
|
||||||
|
|
||||||
|
const current = activeWorkspace.value
|
||||||
|
if (current) {
|
||||||
|
updateActiveWorkspace({
|
||||||
|
pendingInvites: [...current.pendingInvites, invite]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return invite
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a pending invite.
|
||||||
|
*/
|
||||||
|
async function revokeInvite(inviteId: string): Promise<void> {
|
||||||
|
await workspaceApi.revokeInvite(inviteId)
|
||||||
|
const current = activeWorkspace.value
|
||||||
|
if (current) {
|
||||||
|
updateActiveWorkspace({
|
||||||
|
pendingInvites: current.pendingInvites.filter((i) => i.id !== inviteId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept a workspace invite.
|
||||||
|
* Returns workspace info so UI can offer "View Workspace" button.
|
||||||
|
*/
|
||||||
|
async function acceptInvite(
|
||||||
|
token: string
|
||||||
|
): Promise<{ workspaceId: string; workspaceName: string }> {
|
||||||
|
const response = await workspaceApi.acceptInvite(token)
|
||||||
|
|
||||||
|
// Refresh workspace list to include newly joined workspace
|
||||||
|
await refreshWorkspaces()
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspaceId: response.workspace_id,
|
||||||
|
workspaceName: response.workspace_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// INVITE LINK HELPERS
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function buildInviteLink(token: string): string {
|
||||||
|
const baseUrl = window.location.origin
|
||||||
|
return `${baseUrl}?invite=${encodeURIComponent(token)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the invite link for a pending invite.
|
||||||
|
*/
|
||||||
|
function getInviteLink(inviteId: string): string | null {
|
||||||
|
const invite = activeWorkspace.value?.pendingInvites.find(
|
||||||
|
(i) => i.id === inviteId
|
||||||
|
)
|
||||||
|
return invite ? buildInviteLink(invite.token) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an invite link for a given email.
|
||||||
|
*/
|
||||||
|
async function createInviteLink(email: string): Promise<string> {
|
||||||
|
const invite = await createInvite(email)
|
||||||
|
return buildInviteLink(invite.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy an invite link to clipboard.
|
||||||
|
*/
|
||||||
|
async function copyInviteLink(inviteId: string): Promise<string> {
|
||||||
|
const invite = activeWorkspace.value?.pendingInvites.find(
|
||||||
|
(i) => i.id === inviteId
|
||||||
|
)
|
||||||
|
if (!invite) {
|
||||||
|
throw new Error('Invite not found')
|
||||||
|
}
|
||||||
|
const inviteLink = buildInviteLink(invite.token)
|
||||||
|
await navigator.clipboard.writeText(inviteLink)
|
||||||
|
return inviteLink
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// SUBSCRIPTION (placeholder for future integration)
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function subscribeWorkspace(plan: SubscriptionPlan = 'PRO_MONTHLY') {
|
||||||
|
console.warn(plan, 'Billing endpoint has not been added yet.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// CLEANUP
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up store resources (timers, etc.).
|
||||||
|
* Call when the store is no longer needed.
|
||||||
|
*/
|
||||||
|
function destroy(): void {
|
||||||
|
clearTokenContext()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// RETURN
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
initState,
|
||||||
|
workspaces,
|
||||||
|
activeWorkspaceId,
|
||||||
|
error,
|
||||||
|
isCreating,
|
||||||
|
isDeleting,
|
||||||
|
isSwitching,
|
||||||
|
isFetchingWorkspaces,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
activeWorkspace,
|
||||||
|
personalWorkspace,
|
||||||
|
isInPersonalWorkspace,
|
||||||
|
sharedWorkspaces,
|
||||||
|
ownedWorkspacesCount,
|
||||||
|
canCreateWorkspace,
|
||||||
|
members,
|
||||||
|
pendingInvites,
|
||||||
|
totalMemberSlots,
|
||||||
|
isInviteLimitReached,
|
||||||
|
workspaceId,
|
||||||
|
workspaceName,
|
||||||
|
isWorkspaceSubscribed,
|
||||||
|
subscriptionPlan,
|
||||||
|
|
||||||
|
// Initialization & Cleanup
|
||||||
|
initialize,
|
||||||
|
destroy,
|
||||||
|
refreshWorkspaces,
|
||||||
|
|
||||||
|
// Workspace Actions
|
||||||
|
switchWorkspace,
|
||||||
|
createWorkspace,
|
||||||
|
deleteWorkspace,
|
||||||
|
renameWorkspace,
|
||||||
|
updateWorkspaceName,
|
||||||
|
leaveWorkspace,
|
||||||
|
|
||||||
|
// Member Actions
|
||||||
|
fetchMembers,
|
||||||
|
removeMember,
|
||||||
|
|
||||||
|
// Invite Actions
|
||||||
|
fetchPendingInvites,
|
||||||
|
createInvite,
|
||||||
|
revokeInvite,
|
||||||
|
acceptInvite,
|
||||||
|
getInviteLink,
|
||||||
|
createInviteLink,
|
||||||
|
copyInviteLink,
|
||||||
|
|
||||||
|
// Subscription
|
||||||
|
subscribeWorkspace
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user