feat: invite and members working

This commit is contained in:
--list
2026-01-19 15:30:20 -08:00
parent bc698fb746
commit d6bdf4feff
11 changed files with 605 additions and 233 deletions

View File

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

View File

@@ -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')

View File

@@ -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

View File

@@ -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(

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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

View File

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