mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
feat: add workspace session, auth, and store infrastructure (#8194)
## Summary - Add `teamWorkspaceStore` Pinia store for workspace state management (workspaces, members, invites, current workspace) - Add `workspaceApi` client for workspace CRUD, member management, and invite operations - Update `useWorkspaceSwitch` composable for workspace switching logic - Update `useSessionCookie` for workspace-aware sessions - Update `firebaseAuthStore` for workspace aware auth - Use `workspaceAuthStore` for workspace auth flow ## Test plan - [x] 59 unit tests passing (50 store tests + 9 switch tests) - [x] Typecheck passing - [x] Lint passing - [x] Knip passing Note: This PR depends on the `team_workspaces_enabled` feature flag being available (already in main). 🤖 Generated with [Claude Code](https://claude.ai/code) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8194-feat-add-workspace-session-auth-and-store-infrastructure-2ef6d73d3650814984afe8ee7ba0a209) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
|
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||||
|
|
||||||
@@ -19,12 +19,13 @@ export const useSessionCookie = () => {
|
|||||||
const createSession = async (): Promise<void> => {
|
const createSession = async (): Promise<void> => {
|
||||||
if (!isCloud) return
|
if (!isCloud) return
|
||||||
|
|
||||||
|
const { flags } = useFeatureFlags()
|
||||||
try {
|
try {
|
||||||
const authStore = useFirebaseAuthStore()
|
const authStore = useFirebaseAuthStore()
|
||||||
|
|
||||||
let authHeader: Record<string, string>
|
let authHeader: Record<string, string>
|
||||||
|
|
||||||
if (remoteConfig.value.team_workspaces_enabled) {
|
if (flags.teamWorkspacesEnabled) {
|
||||||
const firebaseToken = await authStore.getIdToken()
|
const firebaseToken = await authStore.getIdToken()
|
||||||
if (!firebaseToken) {
|
if (!firebaseToken) {
|
||||||
console.warn(
|
console.warn(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
export const WORKSPACE_STORAGE_KEYS = {
|
export const WORKSPACE_STORAGE_KEYS = {
|
||||||
|
// sessionStorage keys (cleared on browser close)
|
||||||
CURRENT_WORKSPACE: 'Comfy.Workspace.Current',
|
CURRENT_WORKSPACE: 'Comfy.Workspace.Current',
|
||||||
TOKEN: 'Comfy.Workspace.Token',
|
TOKEN: 'Comfy.Workspace.Token',
|
||||||
EXPIRES_AT: 'Comfy.Workspace.ExpiresAt'
|
EXPIRES_AT: 'Comfy.Workspace.ExpiresAt',
|
||||||
|
// localStorage key (persists across browser sessions)
|
||||||
|
LAST_WORKSPACE_ID: 'Comfy.Workspace.LastWorkspaceId'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000
|
export const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000
|
||||||
|
|||||||
335
src/platform/workspace/api/workspaceApi.ts
Normal file
335
src/platform/workspace/api/workspaceApi.ts
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { api } from '@/scripts/api'
|
||||||
|
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||||
|
|
||||||
|
type WorkspaceType = 'personal' | 'team'
|
||||||
|
type WorkspaceRole = 'owner' | 'member'
|
||||||
|
|
||||||
|
interface Workspace {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: WorkspaceType
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceWithRole extends Workspace {
|
||||||
|
role: WorkspaceRole
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Member {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
joined_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginationInfo {
|
||||||
|
offset: number
|
||||||
|
limit: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListMembersResponse {
|
||||||
|
members: Member[]
|
||||||
|
pagination: PaginationInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListMembersParams {
|
||||||
|
offset?: number
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingInvite {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
token: string
|
||||||
|
invited_at: string
|
||||||
|
expires_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListInvitesResponse {
|
||||||
|
invites: PendingInvite[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateInviteRequest {
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AcceptInviteResponse {
|
||||||
|
workspace_id: string
|
||||||
|
workspace_name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BillingPortalRequest {
|
||||||
|
return_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BillingPortalResponse {
|
||||||
|
billing_portal_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateWorkspacePayload {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateWorkspacePayload {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListWorkspacesResponse {
|
||||||
|
workspaces: WorkspaceWithRole[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class WorkspaceApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly status?: number,
|
||||||
|
public readonly code?: string
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'WorkspaceApiError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceApiClient = axios.create({
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function getAuthHeaderOrThrow() {
|
||||||
|
const authHeader = await useFirebaseAuthStore().getAuthHeader()
|
||||||
|
if (!authHeader) {
|
||||||
|
throw new WorkspaceApiError(
|
||||||
|
t('toastMessages.userNotAuthenticated'),
|
||||||
|
401,
|
||||||
|
'NOT_AUTHENTICATED'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return authHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAxiosError(err: unknown): never {
|
||||||
|
if (axios.isAxiosError(err)) {
|
||||||
|
const status = err.response?.status
|
||||||
|
const message = err.response?.data?.message ?? err.message
|
||||||
|
throw new WorkspaceApiError(message, status)
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
export const workspaceApi = {
|
||||||
|
/**
|
||||||
|
* List all workspaces the user has access to
|
||||||
|
* GET /api/workspaces
|
||||||
|
*/
|
||||||
|
async list(): Promise<ListWorkspacesResponse> {
|
||||||
|
const headers = await getAuthHeaderOrThrow()
|
||||||
|
try {
|
||||||
|
const response = await workspaceApiClient.get<ListWorkspacesResponse>(
|
||||||
|
api.apiURL('/workspaces'),
|
||||||
|
{ headers }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
} catch (err) {
|
||||||
|
handleAxiosError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new workspace
|
||||||
|
* POST /api/workspaces
|
||||||
|
*/
|
||||||
|
async create(payload: CreateWorkspacePayload): Promise<WorkspaceWithRole> {
|
||||||
|
const headers = await getAuthHeaderOrThrow()
|
||||||
|
try {
|
||||||
|
const response = await workspaceApiClient.post<WorkspaceWithRole>(
|
||||||
|
api.apiURL('/workspaces'),
|
||||||
|
payload,
|
||||||
|
{ headers }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
} catch (err) {
|
||||||
|
handleAxiosError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update workspace name
|
||||||
|
* PATCH /api/workspaces/:id
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
workspaceId: string,
|
||||||
|
payload: UpdateWorkspacePayload
|
||||||
|
): Promise<WorkspaceWithRole> {
|
||||||
|
const headers = await getAuthHeaderOrThrow()
|
||||||
|
try {
|
||||||
|
const response = await workspaceApiClient.patch<WorkspaceWithRole>(
|
||||||
|
api.apiURL(`/workspaces/${workspaceId}`),
|
||||||
|
payload,
|
||||||
|
{ headers }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
} catch (err) {
|
||||||
|
handleAxiosError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a workspace (owner only)
|
||||||
|
* DELETE /api/workspaces/:id
|
||||||
|
*/
|
||||||
|
async delete(workspaceId: string): Promise<void> {
|
||||||
|
const headers = await getAuthHeaderOrThrow()
|
||||||
|
try {
|
||||||
|
await workspaceApiClient.delete(
|
||||||
|
api.apiURL(`/workspaces/${workspaceId}`),
|
||||||
|
{
|
||||||
|
headers
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
handleAxiosError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leave the current workspace.
|
||||||
|
* POST /api/workspace/leave
|
||||||
|
*/
|
||||||
|
async leave(): Promise<void> {
|
||||||
|
const headers = await getAuthHeaderOrThrow()
|
||||||
|
try {
|
||||||
|
await workspaceApiClient.post(api.apiURL('/workspace/leave'), null, {
|
||||||
|
headers
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
handleAxiosError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List workspace members (paginated).
|
||||||
|
* GET /api/workspace/members
|
||||||
|
*/
|
||||||
|
async listMembers(params?: ListMembersParams): Promise<ListMembersResponse> {
|
||||||
|
const headers = await getAuthHeaderOrThrow()
|
||||||
|
try {
|
||||||
|
const response = await workspaceApiClient.get<ListMembersResponse>(
|
||||||
|
api.apiURL('/workspace/members'),
|
||||||
|
{ headers, params }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
} catch (err) {
|
||||||
|
handleAxiosError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a member from the workspace.
|
||||||
|
* DELETE /api/workspace/members/:userId
|
||||||
|
*/
|
||||||
|
async removeMember(userId: string): Promise<void> {
|
||||||
|
const headers = await getAuthHeaderOrThrow()
|
||||||
|
try {
|
||||||
|
await workspaceApiClient.delete(
|
||||||
|
api.apiURL(`/workspace/members/${userId}`),
|
||||||
|
{ headers }
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
handleAxiosError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List pending invites for the workspace.
|
||||||
|
* GET /api/workspace/invites
|
||||||
|
*/
|
||||||
|
async listInvites(): Promise<ListInvitesResponse> {
|
||||||
|
const headers = await getAuthHeaderOrThrow()
|
||||||
|
try {
|
||||||
|
const response = await workspaceApiClient.get<ListInvitesResponse>(
|
||||||
|
api.apiURL('/workspace/invites'),
|
||||||
|
{ headers }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
} catch (err) {
|
||||||
|
handleAxiosError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an invite for the workspace.
|
||||||
|
* POST /api/workspace/invites
|
||||||
|
*/
|
||||||
|
async createInvite(payload: CreateInviteRequest): Promise<PendingInvite> {
|
||||||
|
const headers = await getAuthHeaderOrThrow()
|
||||||
|
try {
|
||||||
|
const response = await workspaceApiClient.post<PendingInvite>(
|
||||||
|
api.apiURL('/workspace/invites'),
|
||||||
|
payload,
|
||||||
|
{ headers }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
} catch (err) {
|
||||||
|
handleAxiosError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a pending invite.
|
||||||
|
* DELETE /api/workspace/invites/:inviteId
|
||||||
|
*/
|
||||||
|
async revokeInvite(inviteId: string): Promise<void> {
|
||||||
|
const headers = await getAuthHeaderOrThrow()
|
||||||
|
try {
|
||||||
|
await workspaceApiClient.delete(
|
||||||
|
api.apiURL(`/workspace/invites/${inviteId}`),
|
||||||
|
{ headers }
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
handleAxiosError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept a workspace invite.
|
||||||
|
* POST /api/invites/:token/accept
|
||||||
|
*/
|
||||||
|
async acceptInvite(token: string): Promise<AcceptInviteResponse> {
|
||||||
|
const headers = await getAuthHeaderOrThrow()
|
||||||
|
try {
|
||||||
|
const response = await workspaceApiClient.post<AcceptInviteResponse>(
|
||||||
|
api.apiURL(`/invites/${token}/accept`),
|
||||||
|
null,
|
||||||
|
{ headers }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
} catch (err) {
|
||||||
|
handleAxiosError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Access the billing portal for the current workspace.
|
||||||
|
* POST /api/billing/portal
|
||||||
|
*/
|
||||||
|
async accessBillingPortal(
|
||||||
|
returnUrl?: string
|
||||||
|
): Promise<BillingPortalResponse> {
|
||||||
|
const headers = await getAuthHeaderOrThrow()
|
||||||
|
try {
|
||||||
|
const response = await workspaceApiClient.post<BillingPortalResponse>(
|
||||||
|
api.apiURL('/billing/portal'),
|
||||||
|
{
|
||||||
|
return_url: returnUrl ?? window.location.href
|
||||||
|
} satisfies BillingPortalRequest,
|
||||||
|
{ headers }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
} catch (err) {
|
||||||
|
handleAxiosError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
898
src/platform/workspace/stores/teamWorkspaceStore.test.ts
Normal file
898
src/platform/workspace/stores/teamWorkspaceStore.test.ts
Normal file
@@ -0,0 +1,898 @@
|
|||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { useTeamWorkspaceStore } from './teamWorkspaceStore'
|
||||||
|
|
||||||
|
// Mock workspaceAuthStore
|
||||||
|
const mockWorkspaceAuthStore = vi.hoisted(() => ({
|
||||||
|
currentWorkspace: null as {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: 'personal' | 'team'
|
||||||
|
role: 'owner' | 'member'
|
||||||
|
} | null,
|
||||||
|
workspaceToken: null as string | null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null as Error | null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
init: vi.fn(),
|
||||||
|
destroy: vi.fn(),
|
||||||
|
initializeFromSession: vi.fn(),
|
||||||
|
switchWorkspace: vi.fn(),
|
||||||
|
refreshToken: vi.fn(),
|
||||||
|
getWorkspaceAuthHeader: vi.fn(),
|
||||||
|
clearWorkspaceContext: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/workspaceAuthStore', () => ({
|
||||||
|
useWorkspaceAuthStore: () => mockWorkspaceAuthStore
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock workspaceApi
|
||||||
|
const mockWorkspaceApi = vi.hoisted(() => ({
|
||||||
|
list: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
leave: vi.fn(),
|
||||||
|
listMembers: vi.fn(),
|
||||||
|
removeMember: vi.fn(),
|
||||||
|
listInvites: vi.fn(),
|
||||||
|
createInvite: vi.fn(),
|
||||||
|
revokeInvite: vi.fn(),
|
||||||
|
acceptInvite: vi.fn(),
|
||||||
|
accessBillingPortal: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockWorkspaceApiError = vi.hoisted(
|
||||||
|
() =>
|
||||||
|
class WorkspaceApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly status?: number,
|
||||||
|
public readonly code?: string
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'WorkspaceApiError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
vi.mock('../api/workspaceApi', () => ({
|
||||||
|
workspaceApi: mockWorkspaceApi,
|
||||||
|
WorkspaceApiError: mockWorkspaceApiError
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock localStorage
|
||||||
|
const mockLocalStorage = vi.hoisted(() => {
|
||||||
|
const store: Record<string, string> = {}
|
||||||
|
return {
|
||||||
|
getItem: vi.fn((_key: string): string | null => store[_key] ?? null),
|
||||||
|
setItem: vi.fn((key: string, value: string) => {
|
||||||
|
store[key] = value
|
||||||
|
}),
|
||||||
|
removeItem: vi.fn((key: string) => {
|
||||||
|
delete store[key]
|
||||||
|
}),
|
||||||
|
clear: vi.fn(() => {
|
||||||
|
Object.keys(store).forEach((key) => delete store[key])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock window.location.reload
|
||||||
|
const mockReload = vi.fn()
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: { reload: mockReload, origin: 'http://localhost' },
|
||||||
|
writable: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test data
|
||||||
|
const mockPersonalWorkspace = {
|
||||||
|
id: 'ws-personal-123',
|
||||||
|
name: 'Personal',
|
||||||
|
type: 'personal' as const,
|
||||||
|
role: 'owner' as const
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockTeamWorkspace = {
|
||||||
|
id: 'ws-team-456',
|
||||||
|
name: 'Team Alpha',
|
||||||
|
type: 'team' as const,
|
||||||
|
role: 'owner' as const
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockMemberWorkspace = {
|
||||||
|
id: 'ws-team-789',
|
||||||
|
name: 'Team Beta',
|
||||||
|
type: 'team' as const,
|
||||||
|
role: 'member' as const
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useTeamWorkspaceStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.stubGlobal('localStorage', mockLocalStorage)
|
||||||
|
sessionStorage.clear()
|
||||||
|
|
||||||
|
// Reset workspaceAuthStore mock state
|
||||||
|
mockWorkspaceAuthStore.currentWorkspace = null
|
||||||
|
mockWorkspaceAuthStore.workspaceToken = null
|
||||||
|
mockWorkspaceAuthStore.isLoading = false
|
||||||
|
mockWorkspaceAuthStore.error = null
|
||||||
|
mockWorkspaceAuthStore.isAuthenticated = false
|
||||||
|
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(false)
|
||||||
|
mockWorkspaceAuthStore.switchWorkspace.mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
// Default mock responses
|
||||||
|
mockWorkspaceApi.list.mockResolvedValue({
|
||||||
|
workspaces: [mockPersonalWorkspace, mockTeamWorkspace]
|
||||||
|
})
|
||||||
|
mockLocalStorage.getItem.mockReturnValue(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('initial state', () => {
|
||||||
|
it('has correct initial state values', () => {
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
|
||||||
|
expect(store.initState).toBe('uninitialized')
|
||||||
|
expect(store.workspaces).toEqual([])
|
||||||
|
expect(store.activeWorkspaceId).toBeNull()
|
||||||
|
expect(store.error).toBeNull()
|
||||||
|
expect(store.isCreating).toBe(false)
|
||||||
|
expect(store.isDeleting).toBe(false)
|
||||||
|
expect(store.isSwitching).toBe(false)
|
||||||
|
expect(store.isFetchingWorkspaces).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('computed properties return correct defaults', () => {
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
|
||||||
|
expect(store.activeWorkspace).toBeNull()
|
||||||
|
expect(store.personalWorkspace).toBeNull()
|
||||||
|
expect(store.isInPersonalWorkspace).toBe(false)
|
||||||
|
expect(store.sharedWorkspaces).toEqual([])
|
||||||
|
expect(store.ownedWorkspacesCount).toBe(0)
|
||||||
|
expect(store.canCreateWorkspace).toBe(true)
|
||||||
|
expect(store.members).toEqual([])
|
||||||
|
expect(store.pendingInvites).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('initialize', () => {
|
||||||
|
it('fetches workspaces and sets active workspace to personal by default', async () => {
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
expect(mockWorkspaceApi.list).toHaveBeenCalledTimes(1)
|
||||||
|
expect(store.initState).toBe('ready')
|
||||||
|
expect(store.workspaces).toHaveLength(2)
|
||||||
|
expect(store.activeWorkspaceId).toBe(mockPersonalWorkspace.id)
|
||||||
|
expect(mockWorkspaceAuthStore.switchWorkspace).toHaveBeenCalledWith(
|
||||||
|
mockPersonalWorkspace.id
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores workspace from session if valid', async () => {
|
||||||
|
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||||
|
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
expect(store.activeWorkspaceId).toBe(mockTeamWorkspace.id)
|
||||||
|
expect(mockWorkspaceAuthStore.switchWorkspace).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to localStorage if no session', async () => {
|
||||||
|
mockLocalStorage.getItem.mockReturnValue(mockTeamWorkspace.id)
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
expect(store.activeWorkspaceId).toBe(mockTeamWorkspace.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to personal if stored workspace not in list', async () => {
|
||||||
|
mockLocalStorage.getItem.mockReturnValue('non-existent-workspace')
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
expect(store.activeWorkspaceId).toBe(mockPersonalWorkspace.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets error state when workspaces fetch fails', async () => {
|
||||||
|
mockWorkspaceApi.list.mockRejectedValue(new Error('Network error'))
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
|
||||||
|
await expect(store.initialize()).rejects.toThrow('Network error')
|
||||||
|
expect(store.initState).toBe('error')
|
||||||
|
expect(store.error).toBeInstanceOf(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not reinitialize if already initialized', async () => {
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
|
||||||
|
await store.initialize()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
expect(mockWorkspaceApi.list).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when no workspaces available', async () => {
|
||||||
|
mockWorkspaceApi.list.mockResolvedValue({ workspaces: [] })
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
|
||||||
|
await expect(store.initialize()).rejects.toThrow(
|
||||||
|
'No workspaces available'
|
||||||
|
)
|
||||||
|
expect(store.initState).toBe('error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('continues initialization even if token exchange fails', async () => {
|
||||||
|
mockWorkspaceAuthStore.switchWorkspace.mockRejectedValue(
|
||||||
|
new Error('Token exchange failed')
|
||||||
|
)
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
expect(store.initState).toBe('ready')
|
||||||
|
expect(store.activeWorkspaceId).toBe(mockPersonalWorkspace.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('switchWorkspace', () => {
|
||||||
|
it('does nothing if switching to current workspace', async () => {
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
const currentId = store.activeWorkspaceId
|
||||||
|
await store.switchWorkspace(currentId!)
|
||||||
|
|
||||||
|
expect(mockReload).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears context and reloads for valid workspace', async () => {
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
await store.switchWorkspace(mockTeamWorkspace.id)
|
||||||
|
|
||||||
|
expect(mockWorkspaceAuthStore.clearWorkspaceContext).toHaveBeenCalled()
|
||||||
|
expect(mockReload).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets isSwitching flag during operation', async () => {
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
expect(store.isSwitching).toBe(false)
|
||||||
|
|
||||||
|
const switchPromise = store.switchWorkspace(mockTeamWorkspace.id)
|
||||||
|
expect(store.isSwitching).toBe(true)
|
||||||
|
|
||||||
|
await switchPromise
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refreshes workspace list if target not found', async () => {
|
||||||
|
const newWorkspace = {
|
||||||
|
id: 'ws-new-999',
|
||||||
|
name: 'New Workspace',
|
||||||
|
type: 'team' as const,
|
||||||
|
role: 'member' as const
|
||||||
|
}
|
||||||
|
|
||||||
|
mockWorkspaceApi.list
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
workspaces: [mockPersonalWorkspace, mockTeamWorkspace]
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
workspaces: [mockPersonalWorkspace, mockTeamWorkspace, newWorkspace]
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
await store.switchWorkspace(newWorkspace.id)
|
||||||
|
|
||||||
|
expect(mockWorkspaceApi.list).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mockReload).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws if workspace not found after refresh', async () => {
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
store.switchWorkspace('non-existent-workspace')
|
||||||
|
).rejects.toThrow('Workspace not found or access denied')
|
||||||
|
|
||||||
|
expect(store.isSwitching).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createWorkspace', () => {
|
||||||
|
it('creates workspace and triggers reload', async () => {
|
||||||
|
const newWorkspace = {
|
||||||
|
id: 'ws-new-created',
|
||||||
|
name: 'Created Workspace',
|
||||||
|
type: 'team' as const,
|
||||||
|
role: 'owner' as const
|
||||||
|
}
|
||||||
|
mockWorkspaceApi.create.mockResolvedValue(newWorkspace)
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
const result = await store.createWorkspace('Created Workspace')
|
||||||
|
|
||||||
|
expect(mockWorkspaceApi.create).toHaveBeenCalledWith({
|
||||||
|
name: 'Created Workspace'
|
||||||
|
})
|
||||||
|
expect(result.id).toBe(newWorkspace.id)
|
||||||
|
expect(store.workspaces).toContainEqual(
|
||||||
|
expect.objectContaining({ id: newWorkspace.id })
|
||||||
|
)
|
||||||
|
expect(mockWorkspaceAuthStore.clearWorkspaceContext).toHaveBeenCalled()
|
||||||
|
expect(mockReload).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets isCreating flag during operation', async () => {
|
||||||
|
let resolveCreate: (value: unknown) => void
|
||||||
|
const createPromise = new Promise((resolve) => {
|
||||||
|
resolveCreate = resolve
|
||||||
|
})
|
||||||
|
mockWorkspaceApi.create.mockReturnValue(createPromise)
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
expect(store.isCreating).toBe(false)
|
||||||
|
|
||||||
|
const resultPromise = store.createWorkspace('New Workspace')
|
||||||
|
expect(store.isCreating).toBe(true)
|
||||||
|
|
||||||
|
resolveCreate!({
|
||||||
|
id: 'ws-new',
|
||||||
|
name: 'New Workspace',
|
||||||
|
type: 'team',
|
||||||
|
role: 'owner'
|
||||||
|
})
|
||||||
|
await resultPromise
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets isCreating on error', async () => {
|
||||||
|
mockWorkspaceApi.create.mockRejectedValue(new Error('Creation failed'))
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
await expect(store.createWorkspace('New Workspace')).rejects.toThrow(
|
||||||
|
'Creation failed'
|
||||||
|
)
|
||||||
|
expect(store.isCreating).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deleteWorkspace', () => {
|
||||||
|
it('deletes non-active workspace without reload', async () => {
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
expect(store.activeWorkspaceId).toBe(mockPersonalWorkspace.id)
|
||||||
|
|
||||||
|
await store.deleteWorkspace(mockTeamWorkspace.id)
|
||||||
|
|
||||||
|
expect(mockWorkspaceApi.delete).toHaveBeenCalledWith(mockTeamWorkspace.id)
|
||||||
|
expect(store.workspaces).not.toContainEqual(
|
||||||
|
expect.objectContaining({ id: mockTeamWorkspace.id })
|
||||||
|
)
|
||||||
|
expect(mockReload).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes active workspace and reloads to personal', async () => {
|
||||||
|
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||||
|
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
expect(store.activeWorkspaceId).toBe(mockTeamWorkspace.id)
|
||||||
|
|
||||||
|
await store.deleteWorkspace()
|
||||||
|
|
||||||
|
expect(mockWorkspaceApi.delete).toHaveBeenCalledWith(mockTeamWorkspace.id)
|
||||||
|
expect(mockWorkspaceAuthStore.clearWorkspaceContext).toHaveBeenCalled()
|
||||||
|
expect(mockReload).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when trying to delete personal workspace', async () => {
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
store.deleteWorkspace(mockPersonalWorkspace.id)
|
||||||
|
).rejects.toThrow('Cannot delete personal workspace')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when workspace not found', async () => {
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
await expect(store.deleteWorkspace('non-existent')).rejects.toThrow(
|
||||||
|
'Workspace not found'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('renameWorkspace', () => {
|
||||||
|
it('updates workspace name locally', async () => {
|
||||||
|
mockWorkspaceApi.update.mockResolvedValue({
|
||||||
|
...mockTeamWorkspace,
|
||||||
|
name: 'Renamed Workspace'
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
await store.renameWorkspace(mockTeamWorkspace.id, 'Renamed Workspace')
|
||||||
|
|
||||||
|
expect(mockWorkspaceApi.update).toHaveBeenCalledWith(
|
||||||
|
mockTeamWorkspace.id,
|
||||||
|
{ name: 'Renamed Workspace' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const updated = store.workspaces.find(
|
||||||
|
(w) => w.id === mockTeamWorkspace.id
|
||||||
|
)
|
||||||
|
expect(updated?.name).toBe('Renamed Workspace')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('leaveWorkspace', () => {
|
||||||
|
it('leaves workspace and reloads to personal', async () => {
|
||||||
|
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||||
|
mockWorkspaceAuthStore.currentWorkspace = mockMemberWorkspace
|
||||||
|
mockWorkspaceApi.list.mockResolvedValue({
|
||||||
|
workspaces: [mockPersonalWorkspace, mockMemberWorkspace]
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
await store.leaveWorkspace()
|
||||||
|
|
||||||
|
expect(mockWorkspaceApi.leave).toHaveBeenCalled()
|
||||||
|
expect(mockWorkspaceAuthStore.clearWorkspaceContext).toHaveBeenCalled()
|
||||||
|
expect(mockReload).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when trying to leave personal workspace', async () => {
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
await expect(store.leaveWorkspace()).rejects.toThrow(
|
||||||
|
'Cannot leave personal workspace'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('computed properties', () => {
|
||||||
|
it('activeWorkspace returns correct workspace', async () => {
|
||||||
|
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||||
|
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
expect(store.activeWorkspace?.id).toBe(mockTeamWorkspace.id)
|
||||||
|
expect(store.activeWorkspace?.name).toBe(mockTeamWorkspace.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('personalWorkspace returns personal workspace', async () => {
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
expect(store.personalWorkspace?.id).toBe(mockPersonalWorkspace.id)
|
||||||
|
expect(store.personalWorkspace?.type).toBe('personal')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isInPersonalWorkspace returns true when in personal', async () => {
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
expect(store.isInPersonalWorkspace).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isInPersonalWorkspace returns false when in team', async () => {
|
||||||
|
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||||
|
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
expect(store.isInPersonalWorkspace).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sharedWorkspaces excludes personal workspace', async () => {
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
expect(store.sharedWorkspaces).toHaveLength(1)
|
||||||
|
expect(store.sharedWorkspaces[0].id).toBe(mockTeamWorkspace.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ownedWorkspacesCount counts owned workspaces', async () => {
|
||||||
|
mockWorkspaceApi.list.mockResolvedValue({
|
||||||
|
workspaces: [
|
||||||
|
mockPersonalWorkspace,
|
||||||
|
mockTeamWorkspace,
|
||||||
|
mockMemberWorkspace
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
expect(store.ownedWorkspacesCount).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('canCreateWorkspace respects limit', async () => {
|
||||||
|
const manyWorkspaces = Array.from({ length: 10 }, (_, i) => ({
|
||||||
|
id: `ws-owned-${i}`,
|
||||||
|
name: `Owned ${i}`,
|
||||||
|
type: 'team' as const,
|
||||||
|
role: 'owner' as const
|
||||||
|
}))
|
||||||
|
|
||||||
|
mockWorkspaceApi.list.mockResolvedValue({
|
||||||
|
workspaces: [mockPersonalWorkspace, ...manyWorkspaces]
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
expect(store.ownedWorkspacesCount).toBe(11)
|
||||||
|
expect(store.canCreateWorkspace).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('member actions', () => {
|
||||||
|
it('fetchMembers updates active workspace members', async () => {
|
||||||
|
const mockMembers = [
|
||||||
|
{
|
||||||
|
id: 'user-1',
|
||||||
|
name: 'User One',
|
||||||
|
email: 'one@test.com',
|
||||||
|
joined_at: '2024-01-01T00:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'user-2',
|
||||||
|
name: 'User Two',
|
||||||
|
email: 'two@test.com',
|
||||||
|
joined_at: '2024-01-02T00:00:00Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mockWorkspaceApi.listMembers.mockResolvedValue({
|
||||||
|
members: mockMembers,
|
||||||
|
pagination: { offset: 0, limit: 50, total: 2 }
|
||||||
|
})
|
||||||
|
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||||
|
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
const result = await store.fetchMembers()
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(store.members).toHaveLength(2)
|
||||||
|
expect(store.members[0].name).toBe('User One')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchMembers returns empty for personal workspace', async () => {
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
const result = await store.fetchMembers()
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
expect(mockWorkspaceApi.listMembers).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removeMember removes from local list', async () => {
|
||||||
|
const mockMembers = [
|
||||||
|
{
|
||||||
|
id: 'user-1',
|
||||||
|
name: 'User One',
|
||||||
|
email: 'one@test.com',
|
||||||
|
joined_at: '2024-01-01T00:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'user-2',
|
||||||
|
name: 'User Two',
|
||||||
|
email: 'two@test.com',
|
||||||
|
joined_at: '2024-01-02T00:00:00Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mockWorkspaceApi.listMembers.mockResolvedValue({
|
||||||
|
members: mockMembers,
|
||||||
|
pagination: { offset: 0, limit: 50, total: 2 }
|
||||||
|
})
|
||||||
|
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||||
|
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
await store.fetchMembers()
|
||||||
|
|
||||||
|
expect(store.members).toHaveLength(2)
|
||||||
|
|
||||||
|
await store.removeMember('user-1')
|
||||||
|
|
||||||
|
expect(mockWorkspaceApi.removeMember).toHaveBeenCalledWith('user-1')
|
||||||
|
expect(store.members).toHaveLength(1)
|
||||||
|
expect(store.members[0].id).toBe('user-2')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('invite actions', () => {
|
||||||
|
it('fetchPendingInvites updates active workspace invites', async () => {
|
||||||
|
const mockInvites = [
|
||||||
|
{
|
||||||
|
id: 'inv-1',
|
||||||
|
email: 'invite@test.com',
|
||||||
|
token: 'token-abc',
|
||||||
|
invited_at: '2024-01-01T00:00:00Z',
|
||||||
|
expires_at: '2024-01-08T00:00:00Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mockWorkspaceApi.listInvites.mockResolvedValue({ invites: mockInvites })
|
||||||
|
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||||
|
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
const result = await store.fetchPendingInvites()
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(store.pendingInvites).toHaveLength(1)
|
||||||
|
expect(store.pendingInvites[0].email).toBe('invite@test.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createInvite adds to local list', async () => {
|
||||||
|
const newInvite = {
|
||||||
|
id: 'inv-new',
|
||||||
|
email: 'new@test.com',
|
||||||
|
token: 'token-new',
|
||||||
|
invited_at: '2024-01-01T00:00:00Z',
|
||||||
|
expires_at: '2024-01-08T00:00:00Z'
|
||||||
|
}
|
||||||
|
mockWorkspaceApi.createInvite.mockResolvedValue(newInvite)
|
||||||
|
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||||
|
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
const result = await store.createInvite('new@test.com')
|
||||||
|
|
||||||
|
expect(mockWorkspaceApi.createInvite).toHaveBeenCalledWith({
|
||||||
|
email: 'new@test.com'
|
||||||
|
})
|
||||||
|
expect(result.email).toBe('new@test.com')
|
||||||
|
expect(store.pendingInvites).toContainEqual(
|
||||||
|
expect.objectContaining({ email: 'new@test.com' })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('revokeInvite removes from local list', async () => {
|
||||||
|
const mockInvites = [
|
||||||
|
{
|
||||||
|
id: 'inv-1',
|
||||||
|
email: 'one@test.com',
|
||||||
|
token: 'token-1',
|
||||||
|
invited_at: '2024-01-01T00:00:00Z',
|
||||||
|
expires_at: '2024-01-08T00:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'inv-2',
|
||||||
|
email: 'two@test.com',
|
||||||
|
token: 'token-2',
|
||||||
|
invited_at: '2024-01-01T00:00:00Z',
|
||||||
|
expires_at: '2024-01-08T00:00:00Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mockWorkspaceApi.listInvites.mockResolvedValue({ invites: mockInvites })
|
||||||
|
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||||
|
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
await store.fetchPendingInvites()
|
||||||
|
|
||||||
|
await store.revokeInvite('inv-1')
|
||||||
|
|
||||||
|
expect(mockWorkspaceApi.revokeInvite).toHaveBeenCalledWith('inv-1')
|
||||||
|
expect(store.pendingInvites).toHaveLength(1)
|
||||||
|
expect(store.pendingInvites[0].id).toBe('inv-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('acceptInvite refreshes workspace list', async () => {
|
||||||
|
mockWorkspaceApi.acceptInvite.mockResolvedValue({
|
||||||
|
workspace_id: 'ws-joined',
|
||||||
|
workspace_name: 'Joined Workspace'
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
const result = await store.acceptInvite('invite-token')
|
||||||
|
|
||||||
|
expect(mockWorkspaceApi.acceptInvite).toHaveBeenCalledWith('invite-token')
|
||||||
|
expect(result.workspaceId).toBe('ws-joined')
|
||||||
|
expect(result.workspaceName).toBe('Joined Workspace')
|
||||||
|
expect(mockWorkspaceApi.list).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('invite link helpers', () => {
|
||||||
|
it('getInviteLink returns link for existing invite', async () => {
|
||||||
|
const mockInvites = [
|
||||||
|
{
|
||||||
|
id: 'inv-1',
|
||||||
|
email: 'test@test.com',
|
||||||
|
token: 'secret-token',
|
||||||
|
invited_at: '2024-01-01T00:00:00Z',
|
||||||
|
expires_at: '2024-01-08T00:00:00Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mockWorkspaceApi.listInvites.mockResolvedValue({ invites: mockInvites })
|
||||||
|
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||||
|
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
await store.fetchPendingInvites()
|
||||||
|
|
||||||
|
const link = store.getInviteLink('inv-1')
|
||||||
|
|
||||||
|
expect(link).toContain('?invite=secret-token')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getInviteLink returns null for non-existent invite', async () => {
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
const link = store.getInviteLink('non-existent')
|
||||||
|
|
||||||
|
expect(link).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createInviteLink creates invite and returns link', async () => {
|
||||||
|
const newInvite = {
|
||||||
|
id: 'inv-new',
|
||||||
|
email: 'new@test.com',
|
||||||
|
token: 'new-token',
|
||||||
|
invited_at: '2024-01-01T00:00:00Z',
|
||||||
|
expires_at: '2024-01-08T00:00:00Z'
|
||||||
|
}
|
||||||
|
mockWorkspaceApi.createInvite.mockResolvedValue(newInvite)
|
||||||
|
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||||
|
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
const link = await store.createInviteLink('new@test.com')
|
||||||
|
|
||||||
|
expect(link).toContain('?invite=new-token')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('cleanup', () => {
|
||||||
|
it('destroy calls workspaceAuthStore.destroy', async () => {
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
store.destroy()
|
||||||
|
|
||||||
|
expect(mockWorkspaceAuthStore.destroy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('totalMemberSlots and isInviteLimitReached', () => {
|
||||||
|
it('calculates total slots from members and invites', async () => {
|
||||||
|
const mockMembers = [
|
||||||
|
{
|
||||||
|
id: 'user-1',
|
||||||
|
name: 'User One',
|
||||||
|
email: 'one@test.com',
|
||||||
|
joined_at: '2024-01-01T00:00:00Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
const mockInvites = [
|
||||||
|
{
|
||||||
|
id: 'inv-1',
|
||||||
|
email: 'invite@test.com',
|
||||||
|
token: 'token-1',
|
||||||
|
invited_at: '2024-01-01T00:00:00Z',
|
||||||
|
expires_at: '2024-01-08T00:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'inv-2',
|
||||||
|
email: 'invite2@test.com',
|
||||||
|
token: 'token-2',
|
||||||
|
invited_at: '2024-01-01T00:00:00Z',
|
||||||
|
expires_at: '2024-01-08T00:00:00Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mockWorkspaceApi.listMembers.mockResolvedValue({
|
||||||
|
members: mockMembers,
|
||||||
|
pagination: { offset: 0, limit: 50, total: 1 }
|
||||||
|
})
|
||||||
|
mockWorkspaceApi.listInvites.mockResolvedValue({ invites: mockInvites })
|
||||||
|
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||||
|
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
await store.fetchMembers()
|
||||||
|
await store.fetchPendingInvites()
|
||||||
|
|
||||||
|
expect(store.totalMemberSlots).toBe(3)
|
||||||
|
expect(store.isInviteLimitReached).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isInviteLimitReached returns true at 50 slots', async () => {
|
||||||
|
const mockMembers = Array.from({ length: 48 }, (_, i) => ({
|
||||||
|
id: `user-${i}`,
|
||||||
|
name: `User ${i}`,
|
||||||
|
email: `user${i}@test.com`,
|
||||||
|
joined_at: '2024-01-01T00:00:00Z'
|
||||||
|
}))
|
||||||
|
const mockInvites = [
|
||||||
|
{
|
||||||
|
id: 'inv-1',
|
||||||
|
email: 'invite1@test.com',
|
||||||
|
token: 'token-1',
|
||||||
|
invited_at: '2024-01-01T00:00:00Z',
|
||||||
|
expires_at: '2024-01-08T00:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'inv-2',
|
||||||
|
email: 'invite2@test.com',
|
||||||
|
token: 'token-2',
|
||||||
|
invited_at: '2024-01-01T00:00:00Z',
|
||||||
|
expires_at: '2024-01-08T00:00:00Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mockWorkspaceApi.listMembers.mockResolvedValue({
|
||||||
|
members: mockMembers,
|
||||||
|
pagination: { offset: 0, limit: 50, total: 48 }
|
||||||
|
})
|
||||||
|
mockWorkspaceApi.listInvites.mockResolvedValue({ invites: mockInvites })
|
||||||
|
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
|
||||||
|
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
|
||||||
|
|
||||||
|
const store = useTeamWorkspaceStore()
|
||||||
|
await store.initialize()
|
||||||
|
await store.fetchMembers()
|
||||||
|
await store.fetchPendingInvites()
|
||||||
|
|
||||||
|
expect(store.totalMemberSlots).toBe(50)
|
||||||
|
expect(store.isInviteLimitReached).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
610
src/platform/workspace/stores/teamWorkspaceStore.ts
Normal file
610
src/platform/workspace/stores/teamWorkspaceStore.ts
Normal file
@@ -0,0 +1,610 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed, ref, shallowRef } from 'vue'
|
||||||
|
|
||||||
|
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
|
||||||
|
import { useWorkspaceAuthStore } from '@/stores/workspaceAuthStore'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ListMembersParams,
|
||||||
|
Member,
|
||||||
|
PendingInvite as ApiPendingInvite,
|
||||||
|
WorkspaceWithRole
|
||||||
|
} from '../api/workspaceApi'
|
||||||
|
import { workspaceApi } from '../api/workspaceApi'
|
||||||
|
|
||||||
|
interface WorkspaceMember {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
joinDate: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
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: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLastWorkspaceId(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(WORKSPACE_STORAGE_KEYS.LAST_WORKSPACE_ID)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLastWorkspaceId(workspaceId: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(WORKSPACE_STORAGE_KEYS.LAST_WORKSPACE_ID, workspaceId)
|
||||||
|
} catch {
|
||||||
|
console.warn('Failed to persist last workspace ID to localStorage')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_OWNED_WORKSPACES = 10
|
||||||
|
const MAX_WORKSPACE_MEMBERS = 50
|
||||||
|
|
||||||
|
export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||||
|
const initState = ref<InitState>('uninitialized')
|
||||||
|
const workspaces = shallowRef<WorkspaceState[]>([])
|
||||||
|
const activeWorkspaceId = ref<string | null>(null)
|
||||||
|
const error = ref<Error | null>(null)
|
||||||
|
|
||||||
|
const isCreating = ref(false)
|
||||||
|
const isDeleting = ref(false)
|
||||||
|
const isSwitching = ref(false)
|
||||||
|
const isFetchingWorkspaces = ref(false)
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the workspace store.
|
||||||
|
* Fetches workspaces and resolves the active workspace from session/localStorage.
|
||||||
|
* Delegates token management to workspaceAuthStore.
|
||||||
|
* Call once on app boot.
|
||||||
|
*/
|
||||||
|
async function initialize(): Promise<void> {
|
||||||
|
if (initState.value !== 'uninitialized') return
|
||||||
|
|
||||||
|
initState.value = 'loading'
|
||||||
|
isFetchingWorkspaces.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
const workspaceAuthStore = useWorkspaceAuthStore()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Try to restore workspace context from session (page refresh case)
|
||||||
|
const hasValidSession = workspaceAuthStore.initializeFromSession()
|
||||||
|
|
||||||
|
if (hasValidSession && workspaceAuthStore.currentWorkspace) {
|
||||||
|
// Valid session exists - fetch workspace list and sync state
|
||||||
|
const response = await workspaceApi.list()
|
||||||
|
workspaces.value = response.workspaces.map(createWorkspaceState)
|
||||||
|
activeWorkspaceId.value = workspaceAuthStore.currentWorkspace.id
|
||||||
|
initState.value = 'ready'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. No valid session - fetch workspaces and pick default
|
||||||
|
const response = await workspaceApi.list()
|
||||||
|
workspaces.value = response.workspaces.map(createWorkspaceState)
|
||||||
|
|
||||||
|
if (workspaces.value.length === 0) {
|
||||||
|
throw new Error('No workspaces available')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Determine target workspace (priority: localStorage > personal)
|
||||||
|
let targetWorkspaceId: string | null = null
|
||||||
|
|
||||||
|
const lastId = getLastWorkspaceId()
|
||||||
|
if (lastId && workspaces.value.some((w) => w.id === lastId)) {
|
||||||
|
targetWorkspaceId = lastId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetWorkspaceId) {
|
||||||
|
const personal = workspaces.value.find((w) => w.type === 'personal')
|
||||||
|
targetWorkspaceId = personal?.id ?? workspaces.value[0].id
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Exchange Firebase token for workspace token
|
||||||
|
try {
|
||||||
|
await workspaceAuthStore.switchWorkspace(targetWorkspaceId)
|
||||||
|
} catch {
|
||||||
|
// Log but don't fail initialization - API calls will fall back to Firebase token
|
||||||
|
console.error('[teamWorkspaceStore] Token exchange failed during init')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Set active workspace
|
||||||
|
activeWorkspaceId.value = targetWorkspaceId
|
||||||
|
setLastWorkspaceId(targetWorkspaceId)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to a different workspace.
|
||||||
|
* Clears workspace context and reloads the page.
|
||||||
|
*/
|
||||||
|
async function switchWorkspace(workspaceId: string): Promise<void> {
|
||||||
|
if (workspaceId === activeWorkspaceId.value) return
|
||||||
|
|
||||||
|
const workspaceAuthStore = useWorkspaceAuthStore()
|
||||||
|
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear current workspace context and persist new workspace ID
|
||||||
|
workspaceAuthStore.clearWorkspaceContext()
|
||||||
|
setLastWorkspaceId(workspaceId)
|
||||||
|
|
||||||
|
// Reload to reinitialize with new workspace
|
||||||
|
window.location.reload()
|
||||||
|
// 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> {
|
||||||
|
const workspaceAuthStore = useWorkspaceAuthStore()
|
||||||
|
|
||||||
|
isCreating.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newWorkspace = await workspaceApi.create({ name })
|
||||||
|
const workspaceState = createWorkspaceState(newWorkspace)
|
||||||
|
|
||||||
|
// Add to local list
|
||||||
|
workspaces.value = [...workspaces.value, workspaceState]
|
||||||
|
|
||||||
|
// Clear context and switch to new workspace
|
||||||
|
workspaceAuthStore.clearWorkspaceContext()
|
||||||
|
setLastWorkspaceId(newWorkspace.id)
|
||||||
|
window.location.reload()
|
||||||
|
|
||||||
|
// 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')
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceAuthStore = useWorkspaceAuthStore()
|
||||||
|
|
||||||
|
isDeleting.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await workspaceApi.delete(targetId)
|
||||||
|
|
||||||
|
if (targetId === activeWorkspaceId.value) {
|
||||||
|
// Deleted active workspace - go to personal
|
||||||
|
const personal = personalWorkspace.value
|
||||||
|
workspaceAuthStore.clearWorkspaceContext()
|
||||||
|
if (personal) {
|
||||||
|
setLastWorkspaceId(personal.id)
|
||||||
|
}
|
||||||
|
window.location.reload()
|
||||||
|
// 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')
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceAuthStore = useWorkspaceAuthStore()
|
||||||
|
|
||||||
|
await workspaceApi.leave()
|
||||||
|
|
||||||
|
// Go to personal workspace
|
||||||
|
const personal = personalWorkspace.value
|
||||||
|
workspaceAuthStore.clearWorkspaceContext()
|
||||||
|
if (personal) {
|
||||||
|
setLastWorkspaceId(personal.id)
|
||||||
|
}
|
||||||
|
window.location.reload()
|
||||||
|
// Code after this won't run (page reloads)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO: when billing lands update this
|
||||||
|
function subscribeWorkspace(plan: SubscriptionPlan = 'PRO_MONTHLY') {
|
||||||
|
console.warn(plan, 'Billing endpoint has not been added yet.')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up store resources.
|
||||||
|
* Delegates to workspaceAuthStore for token cleanup.
|
||||||
|
*/
|
||||||
|
function destroy(): void {
|
||||||
|
const workspaceAuthStore = useWorkspaceAuthStore()
|
||||||
|
workspaceAuthStore.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -25,12 +25,12 @@ import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
|||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
|
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
|
||||||
import { useTelemetry } from '@/platform/telemetry'
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
import { useDialogService } from '@/services/dialogService'
|
import { useDialogService } from '@/services/dialogService'
|
||||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||||
import type { AuthHeader } from '@/types/authTypes'
|
import type { AuthHeader } from '@/types/authTypes'
|
||||||
import type { operations } from '@/types/comfyRegistryTypes'
|
import type { operations } from '@/types/comfyRegistryTypes'
|
||||||
|
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||||
|
|
||||||
type CreditPurchaseResponse =
|
type CreditPurchaseResponse =
|
||||||
operations['InitiateCreditPurchase']['responses']['201']['content']['application/json']
|
operations['InitiateCreditPurchase']['responses']['201']['content']['application/json']
|
||||||
@@ -58,6 +58,8 @@ export class FirebaseAuthStoreError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||||
|
const { flags } = useFeatureFlags()
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const currentUser = ref<User | null>(null)
|
const currentUser = ref<User | null>(null)
|
||||||
@@ -173,7 +175,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
|||||||
* - null if no authentication method is available
|
* - null if no authentication method is available
|
||||||
*/
|
*/
|
||||||
const getAuthHeader = async (): Promise<AuthHeader | null> => {
|
const getAuthHeader = async (): Promise<AuthHeader | null> => {
|
||||||
if (remoteConfig.value.team_workspaces_enabled) {
|
if (flags.teamWorkspacesEnabled) {
|
||||||
const workspaceToken = sessionStorage.getItem(
|
const workspaceToken = sessionStorage.getItem(
|
||||||
WORKSPACE_STORAGE_KEYS.TOKEN
|
WORKSPACE_STORAGE_KEYS.TOKEN
|
||||||
)
|
)
|
||||||
@@ -201,10 +203,19 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
|||||||
return useApiKeyAuthStore().getAuthHeader()
|
return useApiKeyAuthStore().getAuthHeader()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns Firebase auth header for user-scoped endpoints (e.g., /customers/*).
|
||||||
|
* Use this for endpoints that need user identity, not workspace context.
|
||||||
|
*/
|
||||||
|
const getFirebaseAuthHeader = async (): Promise<AuthHeader | null> => {
|
||||||
|
const token = await getIdToken()
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : null
|
||||||
|
}
|
||||||
|
|
||||||
const fetchBalance = async (): Promise<GetCustomerBalanceResponse | null> => {
|
const fetchBalance = async (): Promise<GetCustomerBalanceResponse | null> => {
|
||||||
isFetchingBalance.value = true
|
isFetchingBalance.value = true
|
||||||
try {
|
try {
|
||||||
const authHeader = await getAuthHeader()
|
const authHeader = await getFirebaseAuthHeader()
|
||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
throw new FirebaseAuthStoreError(
|
throw new FirebaseAuthStoreError(
|
||||||
t('toastMessages.userNotAuthenticated')
|
t('toastMessages.userNotAuthenticated')
|
||||||
@@ -242,7 +253,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createCustomer = async (): Promise<CreateCustomerResponse> => {
|
const createCustomer = async (): Promise<CreateCustomerResponse> => {
|
||||||
const authHeader = await getAuthHeader()
|
const authHeader = await getFirebaseAuthHeader()
|
||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||||
}
|
}
|
||||||
@@ -404,7 +415,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
|||||||
const addCredits = async (
|
const addCredits = async (
|
||||||
requestBodyContent: CreditPurchasePayload
|
requestBodyContent: CreditPurchasePayload
|
||||||
): Promise<CreditPurchaseResponse> => {
|
): Promise<CreditPurchaseResponse> => {
|
||||||
const authHeader = await getAuthHeader()
|
const authHeader = await getFirebaseAuthHeader()
|
||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||||
}
|
}
|
||||||
@@ -444,21 +455,19 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
|||||||
const accessBillingPortal = async (
|
const accessBillingPortal = async (
|
||||||
targetTier?: BillingPortalTargetTier
|
targetTier?: BillingPortalTargetTier
|
||||||
): Promise<AccessBillingPortalResponse> => {
|
): Promise<AccessBillingPortalResponse> => {
|
||||||
const authHeader = await getAuthHeader()
|
const authHeader = await getFirebaseAuthHeader()
|
||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestBody = targetTier ? { target_tier: targetTier } : undefined
|
|
||||||
|
|
||||||
const response = await fetch(buildApiUrl('/customers/billing'), {
|
const response = await fetch(buildApiUrl('/customers/billing'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
...authHeader,
|
...authHeader,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
...(requestBody && {
|
...(targetTier && {
|
||||||
body: JSON.stringify(requestBody)
|
body: JSON.stringify({ target_tier: targetTier })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user