Compare commits

...

2 Commits

Author SHA1 Message Date
--list
3940cc5a9c 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)
2026-01-20 11:57:41 -08:00
--list
a0dc6432fc feat(workspace): add foundation layer - API client and session manager
- Add workspaceApi.ts with all workspace endpoint methods
- Add sessionManager.ts for workspace token storage
- Update workspaceConstants.ts with storage keys
- Add INVITE namespace to preservedQueryNamespaces
2026-01-20 11:55:00 -08:00
11 changed files with 2865 additions and 22 deletions

View File

@@ -1,22 +1,22 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
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 mockCurrentWorkspace = vi.hoisted(() => ({
const mockActiveWorkspace = vi.hoisted(() => ({
value: null as WorkspaceWithRole | null
}))
vi.mock('@/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => ({
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
switchWorkspace: mockSwitchWorkspace
})
}))
vi.mock('pinia', () => ({
storeToRefs: () => ({
currentWorkspace: mockCurrentWorkspace
activeWorkspace: mockActiveWorkspace
})
}))
@@ -46,19 +46,16 @@ vi.mock('vue-i18n', () => ({
})
}))
const mockReload = vi.fn()
describe('useWorkspaceSwitch', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCurrentWorkspace.value = {
mockActiveWorkspace.value = {
id: 'workspace-1',
name: 'Test Workspace',
type: 'personal',
role: 'owner'
}
mockModifiedWorkflows.length = 0
vi.stubGlobal('location', { reload: mockReload })
})
afterEach(() => {
@@ -109,7 +106,6 @@ describe('useWorkspaceSwitch', () => {
expect(result).toBe(true)
expect(mockConfirm).not.toHaveBeenCalled()
expect(mockSwitchWorkspace).toHaveBeenCalledWith('workspace-2')
expect(mockReload).toHaveBeenCalled()
})
it('shows confirmation dialog when there are unsaved changes', async () => {
@@ -136,10 +132,9 @@ describe('useWorkspaceSwitch', () => {
expect(result).toBe(false)
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 })
mockConfirm.mockResolvedValue(true)
mockSwitchWorkspace.mockResolvedValue(undefined)
@@ -149,7 +144,6 @@ describe('useWorkspaceSwitch', () => {
expect(result).toBe(true)
expect(mockSwitchWorkspace).toHaveBeenCalledWith('workspace-2')
expect(mockReload).toHaveBeenCalled()
})
it('returns false if switchWorkspace throws an error', async () => {
@@ -160,7 +154,6 @@ describe('useWorkspaceSwitch', () => {
const result = await switchWithConfirmation('workspace-2')
expect(result).toBe(false)
expect(mockReload).not.toHaveBeenCalled()
})
})
})

View File

@@ -2,13 +2,13 @@ import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
import { useWorkspaceAuthStore } from '@/stores/workspaceAuthStore'
export function useWorkspaceSwitch() {
const { t } = useI18n()
const workspaceAuthStore = useWorkspaceAuthStore()
const { currentWorkspace } = storeToRefs(workspaceAuthStore)
const workspaceStore = useTeamWorkspaceStore()
const { activeWorkspace } = storeToRefs(workspaceStore)
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()
@@ -17,7 +17,7 @@ export function useWorkspaceSwitch() {
}
async function switchWithConfirmation(workspaceId: string): Promise<boolean> {
if (currentWorkspace.value?.id === workspaceId) {
if (activeWorkspace.value?.id === workspaceId) {
return true
}
@@ -34,8 +34,8 @@ export function useWorkspaceSwitch() {
}
try {
await workspaceAuthStore.switchWorkspace(workspaceId)
window.location.reload()
await workspaceStore.switchWorkspace(workspaceId)
// Note: switchWorkspace triggers page reload internally
return true
} catch {
return false

View File

@@ -1,7 +1,10 @@
export const WORKSPACE_STORAGE_KEYS = {
// sessionStorage keys (cleared on browser close)
CURRENT_WORKSPACE: 'Comfy.Workspace.Current',
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
export const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000

View File

@@ -1,3 +1,4 @@
export const PRESERVED_QUERY_NAMESPACES = {
TEMPLATE: 'template'
TEMPLATE: 'template',
INVITE: 'invite'
} as const

View File

@@ -0,0 +1,334 @@
import type { AxiosResponse } from 'axios'
import axios from 'axios'
import { t } from '@/i18n'
import { api } from '@/scripts/api'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { AuthHeader } from '@/types/authTypes'
// Types aligned with backend API
export type WorkspaceType = 'personal' | 'team'
export type WorkspaceRole = 'owner' | 'member'
interface Workspace {
id: string
name: string
type: WorkspaceType
}
export interface WorkspaceWithRole extends Workspace {
role: WorkspaceRole
}
// Member type from API
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
}
// Pending invite type from API
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
}
// Billing types (POST /api/billing/portal)
interface BillingPortalRequest {
return_url: string
}
interface BillingPortalResponse {
billing_portal_url: string
}
interface CreateWorkspacePayload {
name: string
}
interface UpdateWorkspacePayload {
name: string
}
// API responses
interface ListWorkspacesResponse {
workspaces: WorkspaceWithRole[]
}
// Token exchange types (POST /api/auth/token)
interface ExchangeTokenRequest {
workspace_id: string
}
export interface ExchangeTokenResponse {
token: string
expires_at: string
workspace: Workspace
role: WorkspaceRole
permissions: string[]
}
export 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 withAuth<T>(
request: (headers: AuthHeader) => Promise<AxiosResponse<T>>
): Promise<T> {
const authHeader = await useFirebaseAuthStore().getAuthHeader()
if (!authHeader) {
throw new WorkspaceApiError(
t('toastMessages.userNotAuthenticated'),
401,
'NOT_AUTHENTICATED'
)
}
try {
const response = await request(authHeader)
return response.data
} catch (err) {
if (axios.isAxiosError(err)) {
const status = err.response?.status
const message = err.response?.data?.message ?? err.message
throw new WorkspaceApiError(message, status)
}
throw err
}
}
/**
* Wrapper that uses Firebase ID token directly (not workspace token).
* Used for token exchange where we need the Firebase token to get a workspace token.
*/
async function withFirebaseAuth<T>(
request: (headers: AuthHeader) => Promise<AxiosResponse<T>>
): Promise<T> {
const firebaseToken = await useFirebaseAuthStore().getIdToken()
if (!firebaseToken) {
throw new WorkspaceApiError(
t('toastMessages.userNotAuthenticated'),
401,
'NOT_AUTHENTICATED'
)
}
const headers: AuthHeader = { Authorization: `Bearer ${firebaseToken}` }
try {
const response = await request(headers)
return response.data
} catch (err) {
if (axios.isAxiosError(err)) {
const status = err.response?.status
const message = err.response?.data?.message ?? err.message
const code =
status === 401
? 'INVALID_FIREBASE_TOKEN'
: status === 403
? 'ACCESS_DENIED'
: status === 404
? 'WORKSPACE_NOT_FOUND'
: 'TOKEN_EXCHANGE_FAILED'
throw new WorkspaceApiError(message, status, code)
}
throw err
}
}
export const workspaceApi = {
/**
* List all workspaces the user has access to
* GET /api/workspaces
*/
list: (): Promise<ListWorkspacesResponse> =>
withAuth((headers) =>
workspaceApiClient.get(api.apiURL('/workspaces'), { headers })
),
/**
* Create a new workspace
* POST /api/workspaces
*/
create: (payload: CreateWorkspacePayload): Promise<WorkspaceWithRole> =>
withAuth((headers) =>
workspaceApiClient.post(api.apiURL('/workspaces'), payload, { headers })
),
/**
* Update workspace name
* PATCH /api/workspaces/:id
*/
update: (
workspaceId: string,
payload: UpdateWorkspacePayload
): Promise<WorkspaceWithRole> =>
withAuth((headers) =>
workspaceApiClient.patch(
api.apiURL(`/workspaces/${workspaceId}`),
payload,
{ headers }
)
),
/**
* Delete a workspace (owner only)
* DELETE /api/workspaces/:id
*/
delete: (workspaceId: string): Promise<void> =>
withAuth((headers) =>
workspaceApiClient.delete(api.apiURL(`/workspaces/${workspaceId}`), {
headers
})
),
/**
* Leave the current workspace.
* POST /api/workspace/leave
*/
leave: (): Promise<void> =>
withAuth((headers) =>
workspaceApiClient.post(api.apiURL('/workspace/leave'), null, { headers })
),
/**
* List workspace members (paginated).
* GET /api/workspace/members
*/
listMembers: (params?: ListMembersParams): Promise<ListMembersResponse> =>
withAuth((headers) =>
workspaceApiClient.get(api.apiURL('/workspace/members'), {
headers,
params
})
),
/**
* Remove a member from the workspace.
* DELETE /api/workspace/members/:userId
*/
removeMember: (userId: string): Promise<void> =>
withAuth((headers) =>
workspaceApiClient.delete(api.apiURL(`/workspace/members/${userId}`), {
headers
})
),
/**
* List pending invites for the workspace.
* GET /api/workspace/invites
*/
listInvites: (): Promise<ListInvitesResponse> =>
withAuth((headers) =>
workspaceApiClient.get(api.apiURL('/workspace/invites'), { headers })
),
/**
* Create an invite for the workspace.
* POST /api/workspace/invites
*/
createInvite: (payload: CreateInviteRequest): Promise<PendingInvite> =>
withAuth((headers) =>
workspaceApiClient.post(api.apiURL('/workspace/invites'), payload, {
headers
})
),
/**
* Revoke a pending invite.
* DELETE /api/workspace/invites/:inviteId
*/
revokeInvite: (inviteId: string): Promise<void> =>
withAuth((headers) =>
workspaceApiClient.delete(api.apiURL(`/workspace/invites/${inviteId}`), {
headers
})
),
/**
* Accept a workspace invite.
* POST /api/invites/:token/accept
*/
acceptInvite: (token: string): Promise<AcceptInviteResponse> =>
withAuth((headers) =>
workspaceApiClient.post(api.apiURL(`/invites/${token}/accept`), null, {
headers
})
),
/**
* Exchange Firebase JWT for workspace-scoped Cloud JWT.
* POST /api/auth/token
*
* Uses Firebase ID token directly (not getAuthHeader) since we're
* exchanging it for a workspace-scoped token.
*/
exchangeToken: (workspaceId: string): Promise<ExchangeTokenResponse> =>
withFirebaseAuth((headers) =>
workspaceApiClient.post(
api.apiURL('/auth/token'),
{ workspace_id: workspaceId } satisfies ExchangeTokenRequest,
{ headers }
)
),
/**
* Access the billing portal for the current workspace.
* POST /api/billing/portal
*
* Uses workspace-scoped token to get billing portal URL.
*/
accessBillingPortal: (returnUrl?: string): Promise<BillingPortalResponse> =>
withAuth((headers) =>
workspaceApiClient.post(
api.apiURL('/billing/portal'),
{
return_url: returnUrl ?? window.location.href
} satisfies BillingPortalRequest,
{ headers }
)
)
}

View 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()
})
})
})

View 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
}
}

View 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)

View File

@@ -0,0 +1,148 @@
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
/**
* Session manager for workspace context.
* Handles sessionStorage operations and page reloads for workspace switching.
*/
export const sessionManager = {
/**
* Get the current workspace ID from sessionStorage
*/
getCurrentWorkspaceId(): string | null {
try {
return sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
} catch {
return null
}
},
/**
* Set the current workspace ID in sessionStorage
*/
setCurrentWorkspaceId(workspaceId: string): void {
try {
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
workspaceId
)
} catch {
console.warn('Failed to set workspace ID in sessionStorage')
}
},
/**
* Clear the current workspace ID from sessionStorage
*/
clearCurrentWorkspaceId(): void {
try {
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
} catch {
console.warn('Failed to clear workspace ID from sessionStorage')
}
},
/**
* Get the last workspace ID from localStorage (cross-session persistence)
*/
getLastWorkspaceId(): string | null {
try {
return localStorage.getItem(WORKSPACE_STORAGE_KEYS.LAST_WORKSPACE_ID)
} catch {
return null
}
},
/**
* Persist the last workspace ID to localStorage
*/
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')
}
},
/**
* Clear the last workspace ID from localStorage
*/
clearLastWorkspaceId(): void {
try {
localStorage.removeItem(WORKSPACE_STORAGE_KEYS.LAST_WORKSPACE_ID)
} catch {
console.warn('Failed to clear last workspace ID from localStorage')
}
},
/**
* Get the workspace token and expiry from sessionStorage
*/
getWorkspaceToken(): { token: string; expiresAt: number } | null {
try {
const token = sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)
const expiresAtStr = sessionStorage.getItem(
WORKSPACE_STORAGE_KEYS.EXPIRES_AT
)
if (!token || !expiresAtStr) return null
const expiresAt = parseInt(expiresAtStr, 10)
if (isNaN(expiresAt)) return null
return { token, expiresAt }
} catch {
return null
}
},
/**
* Store the workspace token and expiry in sessionStorage
*/
setWorkspaceToken(token: string, expiresAt: number): void {
try {
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, token)
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.EXPIRES_AT,
expiresAt.toString()
)
} catch {
console.warn('Failed to set workspace token in sessionStorage')
}
},
/**
* Clear the workspace token from sessionStorage
*/
clearWorkspaceToken(): void {
try {
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.TOKEN)
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
} catch {
console.warn('Failed to clear workspace token from sessionStorage')
}
},
/**
* Switch workspace and reload the page.
* Clears the old workspace token before reload so fresh token is fetched.
* Code after calling this won't execute (page is gone).
*/
switchWorkspaceAndReload(workspaceId: string): void {
this.clearWorkspaceToken()
this.setCurrentWorkspaceId(workspaceId)
this.setLastWorkspaceId(workspaceId)
window.location.reload()
},
/**
* Clear workspace context and reload (e.g., after deletion).
* Falls back to personal workspace on next boot.
*/
clearAndReload(): void {
this.clearWorkspaceToken()
this.clearCurrentWorkspaceId()
window.location.reload()
}
}

File diff suppressed because it is too large Load Diff

View 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
}
})