mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-31 05:19:53 +00:00
feat: invite and members working
This commit is contained in:
@@ -123,15 +123,11 @@ const { activeTab, setActiveTab, workspaceRole, permissions, uiConfig } =
|
||||
const menu = ref<InstanceType<typeof Menu> | null>(null)
|
||||
|
||||
function handleLeaveWorkspace() {
|
||||
showLeaveWorkspaceDialog(() => {
|
||||
// TODO: Implement actual leave workspace API call
|
||||
})
|
||||
showLeaveWorkspaceDialog()
|
||||
}
|
||||
|
||||
function handleDeleteWorkspace() {
|
||||
showDeleteWorkspaceDialog(() => {
|
||||
// TODO: Implement actual delete workspace API call
|
||||
})
|
||||
showDeleteWorkspaceDialog()
|
||||
}
|
||||
|
||||
// Disable delete when workspace has an active subscription (to prevent accidental deletion)
|
||||
|
||||
@@ -139,6 +139,7 @@ import { useWorkflowService } from '@/platform/workflow/core/services/workflowSe
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
|
||||
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
|
||||
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
@@ -394,6 +395,7 @@ const loadCustomNodesI18n = async () => {
|
||||
|
||||
const comfyAppReady = ref(false)
|
||||
const workflowPersistence = useWorkflowPersistence()
|
||||
const inviteUrlLoader = useInviteUrlLoader()
|
||||
useCanvasDrop(canvasRef)
|
||||
useLitegraphSettings()
|
||||
useNodeBadge()
|
||||
@@ -459,6 +461,9 @@ onMounted(async () => {
|
||||
// Load template from URL if present
|
||||
await workflowPersistence.loadTemplateFromUrlIfPresent()
|
||||
|
||||
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
|
||||
await inviteUrlLoader.loadInviteFromUrl()
|
||||
|
||||
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
|
||||
const { useReleaseStore } =
|
||||
await import('@/platform/updates/common/releaseStore')
|
||||
|
||||
@@ -18,10 +18,7 @@
|
||||
|
||||
<!-- Workspace list -->
|
||||
<template v-else>
|
||||
<template
|
||||
v-for="workspace in availableWorkspaces"
|
||||
:key="workspace.id ?? 'personal'"
|
||||
>
|
||||
<template v-for="workspace in availableWorkspaces" :key="workspace.id">
|
||||
<div class="border-b border-border-default p-2">
|
||||
<div
|
||||
:class="
|
||||
@@ -170,10 +167,6 @@ function getRoleLabel(role: AvailableWorkspace['role']): string {
|
||||
}
|
||||
|
||||
async function handleSelectWorkspace(workspace: AvailableWorkspace) {
|
||||
if (!workspace.id) {
|
||||
// Personal workspace doesn't have an ID in this context
|
||||
return
|
||||
}
|
||||
const success = await switchWithConfirmation(workspace.id)
|
||||
if (success) {
|
||||
emit('select', workspace)
|
||||
@@ -190,7 +183,6 @@ function canDeleteWorkspace(workspace: AvailableWorkspace): boolean {
|
||||
}
|
||||
|
||||
function handleDeleteWorkspace(workspace: AvailableWorkspace) {
|
||||
if (!workspace.id) return
|
||||
showDeleteWorkspaceDialog({
|
||||
workspaceId: workspace.id,
|
||||
workspaceName: workspace.name
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
@@ -24,7 +23,10 @@ export const useSessionCookie = () => {
|
||||
|
||||
let authHeader: Record<string, string>
|
||||
|
||||
if (remoteConfig.value.team_workspaces_enabled) {
|
||||
// TODO: Use remoteConfig.value.team_workspaces_enabled when backend enables the flag
|
||||
// Currently hardcoded to match router.ts behavior
|
||||
const teamWorkspacesEnabled = true
|
||||
if (teamWorkspacesEnabled) {
|
||||
const firebaseToken = await authStore.getIdToken()
|
||||
if (!firebaseToken) {
|
||||
console.warn(
|
||||
|
||||
@@ -6,8 +6,6 @@ import { api } from '@/scripts/api'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import type { AuthHeader } from '@/types/authTypes'
|
||||
|
||||
import { sessionManager } from '../services/sessionManager'
|
||||
|
||||
// Types aligned with backend API
|
||||
export type WorkspaceType = 'personal' | 'team'
|
||||
export type WorkspaceRole = 'owner' | 'member'
|
||||
@@ -68,6 +66,15 @@ export interface AcceptInviteResponse {
|
||||
workspace_name: string
|
||||
}
|
||||
|
||||
// Billing types (POST /api/billing/portal)
|
||||
export interface BillingPortalRequest {
|
||||
return_url: string
|
||||
}
|
||||
|
||||
export interface BillingPortalResponse {
|
||||
billing_portal_url: string
|
||||
}
|
||||
|
||||
export interface CreateWorkspacePayload {
|
||||
name: string
|
||||
}
|
||||
@@ -81,6 +88,19 @@ export interface ListWorkspacesResponse {
|
||||
workspaces: WorkspaceWithRole[]
|
||||
}
|
||||
|
||||
// Token exchange types (POST /api/auth/token)
|
||||
export 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,
|
||||
@@ -98,8 +118,6 @@ const workspaceApiClient = axios.create({
|
||||
}
|
||||
})
|
||||
|
||||
type RequestHeaders = AuthHeader & { 'X-Workspace-ID'?: string }
|
||||
|
||||
async function withAuth<T>(
|
||||
request: (headers: AuthHeader) => Promise<AxiosResponse<T>>
|
||||
): Promise<T> {
|
||||
@@ -125,35 +143,27 @@ async function withAuth<T>(
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper that adds both auth header and workspace ID header.
|
||||
* Use for workspace-scoped endpoints (e.g., /api/workspace/members).
|
||||
* Wrapper for workspace-scoped endpoints (e.g., /api/workspace/members).
|
||||
* The workspace context is determined from the Bearer token.
|
||||
*/
|
||||
async function withWorkspaceAuth<T>(
|
||||
request: (headers: RequestHeaders) => Promise<AxiosResponse<T>>
|
||||
const withWorkspaceAuth = withAuth
|
||||
|
||||
/**
|
||||
* 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 authHeader = await useFirebaseAuthStore().getAuthHeader()
|
||||
if (!authHeader) {
|
||||
const firebaseToken = await useFirebaseAuthStore().getIdToken()
|
||||
if (!firebaseToken) {
|
||||
throw new WorkspaceApiError(
|
||||
t('toastMessages.userNotAuthenticated'),
|
||||
401,
|
||||
'NOT_AUTHENTICATED'
|
||||
)
|
||||
}
|
||||
|
||||
const workspaceId = sessionManager.getCurrentWorkspaceId()
|
||||
if (!workspaceId) {
|
||||
throw new WorkspaceApiError(
|
||||
'No active workspace',
|
||||
400,
|
||||
'NO_ACTIVE_WORKSPACE'
|
||||
)
|
||||
}
|
||||
|
||||
const headers: RequestHeaders = {
|
||||
...authHeader,
|
||||
'X-Workspace-ID': workspaceId
|
||||
}
|
||||
|
||||
const headers: AuthHeader = { Authorization: `Bearer ${firebaseToken}` }
|
||||
try {
|
||||
const response = await request(headers)
|
||||
return response.data
|
||||
@@ -161,7 +171,15 @@ async function withWorkspaceAuth<T>(
|
||||
if (axios.isAxiosError(err)) {
|
||||
const status = err.response?.status
|
||||
const message = err.response?.data?.message ?? err.message
|
||||
throw new WorkspaceApiError(message, status)
|
||||
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
|
||||
}
|
||||
@@ -285,5 +303,36 @@ export const workspaceApi = {
|
||||
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> =>
|
||||
withWorkspaceAuth((headers) =>
|
||||
workspaceApiClient.post(
|
||||
api.apiURL('/billing/portal'),
|
||||
{ return_url: returnUrl ?? window.location.href } satisfies BillingPortalRequest,
|
||||
{ headers }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,10 +10,13 @@ import { useInviteUrlLoader } from './useInviteUrlLoader'
|
||||
* - 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()
|
||||
clearPreservedQuery: vi.fn(),
|
||||
hydratePreservedQuery: vi.fn(),
|
||||
mergePreservedQueryIntoQuery: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
@@ -21,15 +24,27 @@ vi.mock(
|
||||
() => preservedQueryMocks
|
||||
)
|
||||
|
||||
// Mock toast store
|
||||
const mockToastAdd = vi.fn()
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({
|
||||
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
|
||||
})
|
||||
}))
|
||||
|
||||
// Mock i18n
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: vi.fn((key: string, params?: Record<string, unknown>) => {
|
||||
@@ -38,132 +53,73 @@ vi.mock('vue-i18n', () => ({
|
||||
return `You have been added to ${params?.workspaceName}`
|
||||
}
|
||||
if (key === 'workspace.inviteFailed') return 'Failed to Accept Invite'
|
||||
if (key === 'g.error') return 'Error'
|
||||
if (key === 'g.unknownError') return 'Unknown error'
|
||||
return key
|
||||
})
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useInviteUrlLoader', () => {
|
||||
const mockReplaceState = vi.fn()
|
||||
const mockLocation = {
|
||||
search: '',
|
||||
href: 'https://cloud.comfy.org/',
|
||||
origin: 'https://cloud.comfy.org'
|
||||
}
|
||||
const mockAcceptInvite = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../stores/workspaceStore', () => ({
|
||||
useWorkspaceStore: () => ({
|
||||
acceptInvite: mockAcceptInvite
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useInviteUrlLoader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockLocation.search = ''
|
||||
mockLocation.href = 'https://cloud.comfy.org/'
|
||||
|
||||
// Mock location using vi.stubGlobal
|
||||
vi.stubGlobal('location', mockLocation)
|
||||
|
||||
// Mock history.replaceState
|
||||
vi.spyOn(window.history, 'replaceState').mockImplementation(
|
||||
mockReplaceState
|
||||
)
|
||||
mockRouteQuery.value = {}
|
||||
preservedQueryMocks.mergePreservedQueryIntoQuery.mockReturnValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('getInviteTokenFromUrl', () => {
|
||||
it('returns null when no invite param present', () => {
|
||||
window.location.search = ''
|
||||
|
||||
const { getInviteTokenFromUrl } = useInviteUrlLoader()
|
||||
const token = getInviteTokenFromUrl()
|
||||
|
||||
expect(token).toBeNull()
|
||||
})
|
||||
|
||||
it('returns token when invite param is present', () => {
|
||||
window.location.search = '?invite=test-token-123'
|
||||
|
||||
const { getInviteTokenFromUrl } = useInviteUrlLoader()
|
||||
const token = getInviteTokenFromUrl()
|
||||
|
||||
expect(token).toBe('test-token-123')
|
||||
})
|
||||
|
||||
it('returns null for empty invite param', () => {
|
||||
window.location.search = '?invite='
|
||||
|
||||
const { getInviteTokenFromUrl } = useInviteUrlLoader()
|
||||
const token = getInviteTokenFromUrl()
|
||||
|
||||
expect(token).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for whitespace-only invite param', () => {
|
||||
window.location.search = '?invite=%20%20'
|
||||
|
||||
const { getInviteTokenFromUrl } = useInviteUrlLoader()
|
||||
const token = getInviteTokenFromUrl()
|
||||
|
||||
expect(token).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearInviteTokenFromUrl', () => {
|
||||
it('removes invite param from URL', () => {
|
||||
window.location.search = '?invite=test-token'
|
||||
window.location.href = 'https://cloud.comfy.org/?invite=test-token'
|
||||
|
||||
const { clearInviteTokenFromUrl } = useInviteUrlLoader()
|
||||
clearInviteTokenFromUrl()
|
||||
|
||||
expect(mockReplaceState).toHaveBeenCalledWith(
|
||||
window.history.state,
|
||||
'',
|
||||
'https://cloud.comfy.org/'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves other query params when removing invite', () => {
|
||||
window.location.search = '?invite=test-token&other=param'
|
||||
window.location.href =
|
||||
'https://cloud.comfy.org/?invite=test-token&other=param'
|
||||
|
||||
const { clearInviteTokenFromUrl } = useInviteUrlLoader()
|
||||
clearInviteTokenFromUrl()
|
||||
|
||||
expect(mockReplaceState).toHaveBeenCalledWith(
|
||||
window.history.state,
|
||||
'',
|
||||
'https://cloud.comfy.org/?other=param'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadInviteFromUrl', () => {
|
||||
it('does nothing when no invite param present', async () => {
|
||||
window.location.search = ''
|
||||
mockRouteQuery.value = {}
|
||||
|
||||
const mockAcceptInvite = vi.fn()
|
||||
const { loadInviteFromUrl } = useInviteUrlLoader()
|
||||
await loadInviteFromUrl(mockAcceptInvite)
|
||||
await loadInviteFromUrl()
|
||||
|
||||
expect(mockAcceptInvite).not.toHaveBeenCalled()
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
expect(mockReplaceState).not.toHaveBeenCalled()
|
||||
expect(mockRouterReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts invite and shows success toast on success', async () => {
|
||||
window.location.search = '?invite=valid-token'
|
||||
window.location.href = 'https://cloud.comfy.org/?invite=valid-token'
|
||||
|
||||
const mockAcceptInvite = vi.fn().mockResolvedValue({
|
||||
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(mockAcceptInvite)
|
||||
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({
|
||||
@@ -172,64 +128,100 @@ describe('useInviteUrlLoader', () => {
|
||||
detail: 'You have been added to Test Workspace',
|
||||
life: 5000
|
||||
})
|
||||
expect(mockReplaceState).toHaveBeenCalled()
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
'invite'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows error toast when invite acceptance fails', async () => {
|
||||
window.location.search = '?invite=invalid-token'
|
||||
window.location.href = 'https://cloud.comfy.org/?invite=invalid-token'
|
||||
|
||||
const mockAcceptInvite = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Invalid invite'))
|
||||
mockRouteQuery.value = { invite: 'invalid-token' }
|
||||
mockAcceptInvite.mockRejectedValue(new Error('Invalid invite'))
|
||||
|
||||
const { loadInviteFromUrl } = useInviteUrlLoader()
|
||||
await loadInviteFromUrl(mockAcceptInvite)
|
||||
await loadInviteFromUrl()
|
||||
|
||||
expect(mockAcceptInvite).toHaveBeenCalledWith('invalid-token')
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'Failed to Accept Invite',
|
||||
detail: 'Error',
|
||||
detail: 'Invalid invite',
|
||||
life: 5000
|
||||
})
|
||||
})
|
||||
|
||||
it('cleans up URL even on error', async () => {
|
||||
window.location.search = '?invite=invalid-token'
|
||||
window.location.href = 'https://cloud.comfy.org/?invite=invalid-token'
|
||||
|
||||
const mockAcceptInvite = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Invalid invite'))
|
||||
|
||||
const { loadInviteFromUrl } = useInviteUrlLoader()
|
||||
await loadInviteFromUrl(mockAcceptInvite)
|
||||
|
||||
expect(mockReplaceState).toHaveBeenCalled()
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
'invite'
|
||||
)
|
||||
})
|
||||
|
||||
it('clears preserved query on success', async () => {
|
||||
window.location.search = '?invite=valid-token'
|
||||
window.location.href = 'https://cloud.comfy.org/?invite=valid-token'
|
||||
|
||||
const mockAcceptInvite = vi.fn().mockResolvedValue({
|
||||
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(mockAcceptInvite)
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
|
||||
import {
|
||||
clearPreservedQuery,
|
||||
hydratePreservedQuery,
|
||||
mergePreservedQueryIntoQuery
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
type AcceptInviteFn = (
|
||||
token: string
|
||||
) => Promise<{ workspaceId: string; workspaceName: string }>
|
||||
import { useWorkspaceStore } from '../stores/workspaceStore'
|
||||
|
||||
const LOG_PREFIX = '[useInviteUrlLoader]'
|
||||
|
||||
/**
|
||||
* Composable for loading workspace invites from URL query parameters
|
||||
@@ -14,47 +19,85 @@ type AcceptInviteFn = (
|
||||
* Supports URLs like:
|
||||
* - /?invite=TOKEN (accepts workspace invite)
|
||||
*
|
||||
* Input validation:
|
||||
* - Token parameter must be a non-empty string
|
||||
* 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 toastStore = useToastStore()
|
||||
const toast = useToast()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const INVITE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.INVITE
|
||||
|
||||
/**
|
||||
* Gets the invite token from URL query parameters
|
||||
* Hydrates preserved query from sessionStorage and merges into route.
|
||||
* This restores the invite token after login redirects.
|
||||
*/
|
||||
function getInviteTokenFromUrl(): string | null {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const token = params.get('invite')
|
||||
return token && token.trim().length > 0 ? token : null
|
||||
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 the invite parameter from URL without triggering navigation
|
||||
* Removes invite parameter from URL using Vue Router
|
||||
*/
|
||||
function clearInviteTokenFromUrl() {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('invite')
|
||||
window.history.replaceState(window.history.state, '', url.toString())
|
||||
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
|
||||
* 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
|
||||
*/
|
||||
async function loadInviteFromUrl(acceptInvite: AcceptInviteFn) {
|
||||
const token = getInviteTokenFromUrl()
|
||||
const loadInviteFromUrl = async () => {
|
||||
console.log(LOG_PREFIX, 'Starting invite URL loading')
|
||||
console.log(LOG_PREFIX, 'Current route.query:', route.query)
|
||||
|
||||
if (!token) {
|
||||
// Restore preserved query from sessionStorage (handles login redirect case)
|
||||
const query = await ensureInviteQueryFromIntent()
|
||||
console.log(LOG_PREFIX, 'Query after hydration:', query)
|
||||
|
||||
const inviteParam = query.invite
|
||||
console.log(
|
||||
LOG_PREFIX,
|
||||
'Invite param:',
|
||||
inviteParam,
|
||||
'type:',
|
||||
typeof inviteParam
|
||||
)
|
||||
|
||||
if (!inviteParam || typeof inviteParam !== 'string') {
|
||||
console.log(LOG_PREFIX, 'No valid invite param found, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await acceptInvite(token)
|
||||
console.log(LOG_PREFIX, 'Accepting invite with token:', inviteParam)
|
||||
|
||||
toastStore.add({
|
||||
try {
|
||||
const result = await workspaceStore.acceptInvite(inviteParam)
|
||||
console.log(LOG_PREFIX, 'Invite accepted successfully:', result)
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('workspace.inviteAccepted'),
|
||||
detail: t('workspace.addedToWorkspace', {
|
||||
@@ -63,22 +106,20 @@ export function useInviteUrlLoader() {
|
||||
life: 5000
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[useInviteUrlLoader] Failed to accept invite:', error)
|
||||
toastStore.add({
|
||||
console.error(LOG_PREFIX, 'Failed to accept invite:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspace.inviteFailed'),
|
||||
detail: t('g.error'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
})
|
||||
} finally {
|
||||
clearInviteTokenFromUrl()
|
||||
cleanupUrlParams()
|
||||
clearPreservedQuery(INVITE_NAMESPACE)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getInviteTokenFromUrl,
|
||||
clearInviteTokenFromUrl,
|
||||
loadInviteFromUrl
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,11 +77,60 @@ export const sessionManager = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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()
|
||||
@@ -92,6 +141,7 @@ export const sessionManager = {
|
||||
* Falls back to personal workspace on next boot.
|
||||
*/
|
||||
clearAndReload(): void {
|
||||
this.clearWorkspaceToken()
|
||||
this.clearCurrentWorkspaceId()
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
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 } from '../api/workspaceApi'
|
||||
import { workspaceApi, WorkspaceApiError } from '../api/workspaceApi'
|
||||
|
||||
// Extended member type for UI (adds joinDate as Date)
|
||||
export interface WorkspaceMember {
|
||||
@@ -87,6 +90,11 @@ export const useWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
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
|
||||
// ════════════════════════════════════════════════════════════
|
||||
@@ -168,6 +176,150 @@ export const useWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
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
|
||||
// ════════════════════════════════════════════════════════════
|
||||
@@ -180,6 +332,7 @@ export const useWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
async function initialize(): Promise<void> {
|
||||
if (initState.value !== 'uninitialized') return
|
||||
|
||||
console.log('[workspaceStore] Initializing workspace store...')
|
||||
initState.value = 'loading'
|
||||
isFetchingWorkspaces.value = true
|
||||
error.value = null
|
||||
@@ -187,6 +340,16 @@ export const useWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
try {
|
||||
// 1. Fetch all workspaces
|
||||
const response = await workspaceApi.list()
|
||||
console.log('[workspaceStore] initialize API response:', response)
|
||||
console.log(
|
||||
'[workspaceStore] Workspaces from API:',
|
||||
response.workspaces.map((w) => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
type: w.type,
|
||||
role: w.role
|
||||
}))
|
||||
)
|
||||
workspaces.value = response.workspaces.map(createWorkspaceState)
|
||||
|
||||
if (workspaces.value.length === 0) {
|
||||
@@ -221,6 +384,23 @@ export const useWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
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')
|
||||
@@ -238,6 +418,15 @@ export const useWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
isFetchingWorkspaces.value = true
|
||||
try {
|
||||
const response = await workspaceApi.list()
|
||||
console.log('[workspaceStore] refreshWorkspaces API response:', response)
|
||||
console.log(
|
||||
'[workspaceStore] Workspace IDs:',
|
||||
response.workspaces.map((w) => w.id)
|
||||
)
|
||||
console.log(
|
||||
'[workspaceStore] Workspace names:',
|
||||
response.workspaces.map((w) => w.name)
|
||||
)
|
||||
workspaces.value = response.workspaces.map(createWorkspaceState)
|
||||
} finally {
|
||||
isFetchingWorkspaces.value = false
|
||||
@@ -255,6 +444,9 @@ export const useWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
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 {
|
||||
@@ -396,10 +588,23 @@ export const useWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
async function fetchMembers(
|
||||
params?: ListMembersParams
|
||||
): Promise<WorkspaceMember[]> {
|
||||
const sessionWorkspaceId = sessionManager.getCurrentWorkspaceId()
|
||||
console.log('[workspaceStore] fetchMembers called')
|
||||
console.log(
|
||||
'[workspaceStore] activeWorkspaceId (store):',
|
||||
activeWorkspaceId.value
|
||||
)
|
||||
console.log('[workspaceStore] sessionWorkspaceId:', sessionWorkspaceId)
|
||||
console.log(
|
||||
'[workspaceStore] IDs match:',
|
||||
activeWorkspaceId.value === sessionWorkspaceId
|
||||
)
|
||||
console.log('[workspaceStore] activeWorkspace:', activeWorkspace.value)
|
||||
if (!activeWorkspaceId.value) return []
|
||||
if (activeWorkspace.value?.type === 'personal') return []
|
||||
|
||||
const response = await workspaceApi.listMembers(params)
|
||||
console.log('[workspaceStore] fetchMembers response:', response)
|
||||
const members = response.members.map(mapApiMemberToWorkspaceMember)
|
||||
updateActiveWorkspace({ members })
|
||||
return members
|
||||
@@ -426,10 +631,23 @@ export const useWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
* Fetch pending invites for the current workspace.
|
||||
*/
|
||||
async function fetchPendingInvites(): Promise<PendingInvite[]> {
|
||||
const sessionWorkspaceId = sessionManager.getCurrentWorkspaceId()
|
||||
console.log('[workspaceStore] fetchPendingInvites called')
|
||||
console.log(
|
||||
'[workspaceStore] activeWorkspaceId (store):',
|
||||
activeWorkspaceId.value
|
||||
)
|
||||
console.log('[workspaceStore] sessionWorkspaceId:', sessionWorkspaceId)
|
||||
console.log(
|
||||
'[workspaceStore] IDs match:',
|
||||
activeWorkspaceId.value === sessionWorkspaceId
|
||||
)
|
||||
console.log('[workspaceStore] activeWorkspace:', activeWorkspace.value)
|
||||
if (!activeWorkspaceId.value) return []
|
||||
if (activeWorkspace.value?.type === 'personal') return []
|
||||
|
||||
const response = await workspaceApi.listInvites()
|
||||
console.log('[workspaceStore] fetchPendingInvites response:', response)
|
||||
const invites = response.invites.map(mapApiInviteToPendingInvite)
|
||||
updateActiveWorkspace({ pendingInvites: invites })
|
||||
return invites
|
||||
@@ -472,11 +690,23 @@ export const useWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
async function acceptInvite(
|
||||
token: string
|
||||
): Promise<{ workspaceId: string; workspaceName: string }> {
|
||||
console.log('[workspaceStore] acceptInvite called with token:', token)
|
||||
console.log(
|
||||
'[workspaceStore] Workspaces BEFORE accept:',
|
||||
workspaces.value.map((w) => ({ id: w.id, name: w.name, type: w.type }))
|
||||
)
|
||||
|
||||
const response = await workspaceApi.acceptInvite(token)
|
||||
console.log('[workspaceStore] acceptInvite API response:', response)
|
||||
|
||||
// Refresh workspace list to include newly joined workspace
|
||||
await refreshWorkspaces()
|
||||
|
||||
console.log(
|
||||
'[workspaceStore] Workspaces AFTER refresh:',
|
||||
workspaces.value.map((w) => ({ id: w.id, name: w.name, type: w.type }))
|
||||
)
|
||||
|
||||
return {
|
||||
workspaceId: response.workspace_id,
|
||||
workspaceName: response.workspace_name
|
||||
@@ -536,6 +766,18 @@ export const useWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
})
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// CLEANUP
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Clean up store resources (timers, etc.).
|
||||
* Call when the store is no longer needed.
|
||||
*/
|
||||
function destroy(): void {
|
||||
clearTokenContext()
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// DEV HELPERS
|
||||
// ════════════════════════════════════════════════════════════
|
||||
@@ -583,8 +825,9 @@ export const useWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
isWorkspaceSubscribed,
|
||||
subscriptionPlan,
|
||||
|
||||
// Initialization
|
||||
// Initialization & Cleanup
|
||||
initialize,
|
||||
destroy,
|
||||
refreshWorkspaces,
|
||||
|
||||
// Workspace Actions
|
||||
|
||||
@@ -184,7 +184,6 @@ if (isCloud) {
|
||||
|
||||
// Initialize workspace context for logged-in users navigating to root
|
||||
// This must happen before the app loads to ensure workspace context is ready
|
||||
// and to handle invite URLs early in the lifecycle
|
||||
// TODO: Use flags.teamWorkspacesEnabled when backend enables the flag
|
||||
const teamWorkspacesEnabled = true
|
||||
if (to.path === '/' && teamWorkspacesEnabled) {
|
||||
@@ -195,12 +194,6 @@ if (isCloud) {
|
||||
if (workspaceStore.initState === 'uninitialized') {
|
||||
try {
|
||||
await workspaceStore.initialize()
|
||||
|
||||
// Handle invite URL if present (e.g., ?invite=TOKEN)
|
||||
const { useInviteUrlLoader } =
|
||||
await import('@/platform/workspace/composables/useInviteUrlLoader')
|
||||
const { loadInviteFromUrl } = useInviteUrlLoader()
|
||||
await loadInviteFromUrl(workspaceStore.acceptInvite)
|
||||
} catch (error) {
|
||||
console.error('Workspace initialization failed:', error)
|
||||
// Continue anyway - workspace features will be degraded
|
||||
|
||||
@@ -25,7 +25,6 @@ import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
@@ -173,7 +172,10 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
* - null if no authentication method is available
|
||||
*/
|
||||
const getAuthHeader = async (): Promise<AuthHeader | null> => {
|
||||
if (remoteConfig.value.team_workspaces_enabled) {
|
||||
// TODO: Use remoteConfig.value.team_workspaces_enabled when backend enables the flag
|
||||
// Currently hardcoded to match router.ts behavior
|
||||
const teamWorkspacesEnabled = true
|
||||
if (teamWorkspacesEnabled) {
|
||||
const workspaceToken = sessionStorage.getItem(
|
||||
WORKSPACE_STORAGE_KEYS.TOKEN
|
||||
)
|
||||
@@ -201,10 +203,19 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
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> => {
|
||||
isFetchingBalance.value = true
|
||||
try {
|
||||
const authHeader = await getAuthHeader()
|
||||
const authHeader = await getFirebaseAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new FirebaseAuthStoreError(
|
||||
t('toastMessages.userNotAuthenticated')
|
||||
@@ -242,7 +253,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
}
|
||||
|
||||
const createCustomer = async (): Promise<CreateCustomerResponse> => {
|
||||
const authHeader = await getAuthHeader()
|
||||
const authHeader = await getFirebaseAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
}
|
||||
@@ -404,7 +415,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
const addCredits = async (
|
||||
requestBodyContent: CreditPurchasePayload
|
||||
): Promise<CreditPurchaseResponse> => {
|
||||
const authHeader = await getAuthHeader()
|
||||
const authHeader = await getFirebaseAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
}
|
||||
@@ -444,21 +455,19 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
const accessBillingPortal = async (
|
||||
targetTier?: BillingPortalTargetTier
|
||||
): Promise<AccessBillingPortalResponse> => {
|
||||
const authHeader = await getAuthHeader()
|
||||
const authHeader = await getFirebaseAuthHeader()
|
||||
if (!authHeader) {
|
||||
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||
}
|
||||
|
||||
const requestBody = targetTier ? { target_tier: targetTier } : undefined
|
||||
|
||||
const response = await fetch(buildApiUrl('/customers/billing'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...authHeader,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
...(requestBody && {
|
||||
body: JSON.stringify(requestBody)
|
||||
...(targetTier && {
|
||||
body: JSON.stringify({ target_tier: targetTier })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user