feat: add per-tab workspace authentication infrastructure (#8073)

## Summary
Add workspace authentication composables and types for per-tab workspace
isolation. This infrastructure enables users to work in different
workspaces in different browser tabs.

## Changes
- **useWorkspaceAuth composable** - workspace token management
- Exchange Firebase token for workspace-scoped JWT via `POST
/api/auth/token`
  - Auto-refresh tokens 5 minutes before expiry
  - Per-tab sessionStorage caching
- **useWorkspaceSwitch composable** - workspace switching with unsaved
changes confirmation
- **WorkspaceWithRole/WorkspaceTokenResponse types** - aligned with
backend API
- **firebaseAuthStore.getAuthHeader()** - prioritizes workspace tokens
over Firebase tokens
- **useSessionCookie** - uses Firebase token directly (getIdToken())
since getAuthHeader() now returns workspace token

## Backend Dependency
- `POST /api/auth/token` - exchange Firebase token for workspace token
- `GET /api/workspaces` - list user's workspaces

## Related
- https://github.com/Comfy-Org/ComfyUI_frontend/pull/6295

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8073-feat-add-per-tab-workspace-authentication-infrastructure-2e96d73d3650816c8cf9dae9c330aebb)
by [Unito](https://www.unito.io)

---------

Co-authored-by: anthropic/claude <noreply@anthropic.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
This commit is contained in:
Christian Byrne
2026-01-15 17:24:48 -08:00
committed by GitHub
parent 5409bf86a9
commit 6048fab239
12 changed files with 1392 additions and 36 deletions

View File

@@ -1,5 +1,6 @@
import { api } from '@/scripts/api'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
/**
@@ -10,31 +11,59 @@ export const useSessionCookie = () => {
/**
* Creates or refreshes the session cookie.
* Called after login and on token refresh.
*
* When team_workspaces_enabled is true, uses Firebase token directly
* (since getAuthHeader() returns workspace token which shouldn't be used for session creation).
* When disabled, uses getAuthHeader() for backward compatibility.
*/
const createSession = async (): Promise<void> => {
if (!isCloud) return
const authStore = useFirebaseAuthStore()
const authHeader = await authStore.getAuthHeader()
try {
const authStore = useFirebaseAuthStore()
if (!authHeader) {
throw new Error('No auth header available for session creation')
}
let authHeader: Record<string, string>
const response = await fetch(api.apiURL('/auth/session'), {
method: 'POST',
credentials: 'include',
headers: {
...authHeader,
'Content-Type': 'application/json'
if (remoteConfig.value.team_workspaces_enabled) {
const firebaseToken = await authStore.getIdToken()
if (!firebaseToken) {
console.warn(
'Failed to create session cookie:',
'No Firebase token available for session creation'
)
return
}
authHeader = { Authorization: `Bearer ${firebaseToken}` }
} else {
const header = await authStore.getAuthHeader()
if (!header) {
console.warn(
'Failed to create session cookie:',
'No auth header available for session creation'
)
return
}
authHeader = header
}
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(
`Failed to create session: ${errorData.message || response.statusText}`
)
const response = await fetch(api.apiURL('/auth/session'), {
method: 'POST',
credentials: 'include',
headers: {
...authHeader,
'Content-Type': 'application/json'
}
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
console.warn(
'Failed to create session cookie:',
errorData.message || response.statusText
)
}
} catch (error) {
console.warn('Failed to create session cookie:', error)
}
}
@@ -45,16 +74,21 @@ export const useSessionCookie = () => {
const deleteSession = async (): Promise<void> => {
if (!isCloud) return
const response = await fetch(api.apiURL('/auth/session'), {
method: 'DELETE',
credentials: 'include'
})
try {
const response = await fetch(api.apiURL('/auth/session'), {
method: 'DELETE',
credentials: 'include'
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(
`Failed to delete session: ${errorData.message || response.statusText}`
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
console.warn(
'Failed to delete session cookie:',
errorData.message || response.statusText
)
}
} catch (error) {
console.warn('Failed to delete session cookie:', error)
}
}

View File

@@ -0,0 +1,670 @@
import { createPinia, setActivePinia, storeToRefs } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
useWorkspaceAuthStore,
WorkspaceAuthError
} from '@/stores/workspaceAuthStore'
import { WORKSPACE_STORAGE_KEYS } from './workspaceConstants'
const mockGetIdToken = vi.fn()
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
getIdToken: mockGetIdToken
})
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (route: string) => `https://api.example.com/api${route}`
}
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
const mockRemoteConfig = vi.hoisted(() => ({
value: {
team_workspaces_enabled: true
}
}))
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
remoteConfig: mockRemoteConfig
}))
const mockWorkspace = {
id: 'workspace-123',
name: 'Test Workspace',
type: 'team' as const
}
const mockWorkspaceWithRole = {
...mockWorkspace,
role: 'owner' as const
}
const mockTokenResponse = {
token: 'workspace-token-abc',
expires_at: new Date(Date.now() + 3600 * 1000).toISOString(),
workspace: mockWorkspace,
role: 'owner' as const,
permissions: ['owner:*']
}
describe('useWorkspaceAuthStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
vi.useFakeTimers()
sessionStorage.clear()
})
afterEach(() => {
vi.useRealTimers()
})
describe('initial state', () => {
it('has correct initial state values', () => {
const store = useWorkspaceAuthStore()
const {
currentWorkspace,
workspaceToken,
isAuthenticated,
isLoading,
error
} = storeToRefs(store)
expect(currentWorkspace.value).toBeNull()
expect(workspaceToken.value).toBeNull()
expect(isAuthenticated.value).toBe(false)
expect(isLoading.value).toBe(false)
expect(error.value).toBeNull()
})
})
describe('initializeFromSession', () => {
it('returns true and populates state when valid session data exists', () => {
const futureExpiry = Date.now() + 3600 * 1000
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
JSON.stringify(mockWorkspaceWithRole)
)
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, 'valid-token')
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.EXPIRES_AT,
futureExpiry.toString()
)
const store = useWorkspaceAuthStore()
const { currentWorkspace, workspaceToken } = storeToRefs(store)
const result = store.initializeFromSession()
expect(result).toBe(true)
expect(currentWorkspace.value).toEqual(mockWorkspaceWithRole)
expect(workspaceToken.value).toBe('valid-token')
})
it('returns false when sessionStorage is empty', () => {
const store = useWorkspaceAuthStore()
const result = store.initializeFromSession()
expect(result).toBe(false)
})
it('returns false and clears storage when token is expired', () => {
const pastExpiry = Date.now() - 1000
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
JSON.stringify(mockWorkspaceWithRole)
)
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, 'expired-token')
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.EXPIRES_AT,
pastExpiry.toString()
)
const store = useWorkspaceAuthStore()
const result = store.initializeFromSession()
expect(result).toBe(false)
expect(
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
).toBeNull()
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)).toBeNull()
expect(
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
).toBeNull()
})
it('returns false and clears storage when data is malformed', () => {
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
'invalid-json{'
)
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, 'some-token')
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT, 'not-a-number')
const store = useWorkspaceAuthStore()
const result = store.initializeFromSession()
expect(result).toBe(false)
expect(
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
).toBeNull()
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)).toBeNull()
expect(
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
).toBeNull()
})
it('returns false when partial session data exists (missing token)', () => {
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
JSON.stringify(mockWorkspaceWithRole)
)
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.EXPIRES_AT,
(Date.now() + 3600 * 1000).toString()
)
const store = useWorkspaceAuthStore()
const result = store.initializeFromSession()
expect(result).toBe(false)
})
})
describe('switchWorkspace', () => {
it('successfully exchanges Firebase token for workspace token', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
)
const store = useWorkspaceAuthStore()
const { currentWorkspace, workspaceToken, isAuthenticated } =
storeToRefs(store)
await store.switchWorkspace('workspace-123')
expect(currentWorkspace.value).toEqual(mockWorkspaceWithRole)
expect(workspaceToken.value).toBe('workspace-token-abc')
expect(isAuthenticated.value).toBe(true)
})
it('stores workspace data in sessionStorage', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
)
const store = useWorkspaceAuthStore()
await store.switchWorkspace('workspace-123')
expect(
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
).toBe(JSON.stringify(mockWorkspaceWithRole))
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)).toBe(
'workspace-token-abc'
)
expect(
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
).toBeTruthy()
})
it('sets isLoading to true during operation', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
let resolveResponse: (value: unknown) => void
const responsePromise = new Promise((resolve) => {
resolveResponse = resolve
})
vi.stubGlobal('fetch', vi.fn().mockReturnValue(responsePromise))
const store = useWorkspaceAuthStore()
const { isLoading } = storeToRefs(store)
const switchPromise = store.switchWorkspace('workspace-123')
expect(isLoading.value).toBe(true)
resolveResponse!({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
await switchPromise
expect(isLoading.value).toBe(false)
})
it('throws WorkspaceAuthError with code NOT_AUTHENTICATED when Firebase token unavailable', async () => {
mockGetIdToken.mockResolvedValue(undefined)
const store = useWorkspaceAuthStore()
const { error } = storeToRefs(store)
await expect(store.switchWorkspace('workspace-123')).rejects.toThrow(
WorkspaceAuthError
)
expect(error.value).toBeInstanceOf(WorkspaceAuthError)
expect((error.value as WorkspaceAuthError).code).toBe('NOT_AUTHENTICATED')
})
it('throws WorkspaceAuthError with code ACCESS_DENIED on 403 response', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 403,
statusText: 'Forbidden',
json: () => Promise.resolve({ message: 'Access denied' })
})
)
const store = useWorkspaceAuthStore()
const { error } = storeToRefs(store)
await expect(store.switchWorkspace('workspace-123')).rejects.toThrow(
WorkspaceAuthError
)
expect(error.value).toBeInstanceOf(WorkspaceAuthError)
expect((error.value as WorkspaceAuthError).code).toBe('ACCESS_DENIED')
})
it('throws WorkspaceAuthError with code WORKSPACE_NOT_FOUND on 404 response', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
json: () => Promise.resolve({ message: 'Workspace not found' })
})
)
const store = useWorkspaceAuthStore()
const { error } = storeToRefs(store)
await expect(store.switchWorkspace('workspace-123')).rejects.toThrow(
WorkspaceAuthError
)
expect(error.value).toBeInstanceOf(WorkspaceAuthError)
expect((error.value as WorkspaceAuthError).code).toBe(
'WORKSPACE_NOT_FOUND'
)
})
it('throws WorkspaceAuthError with code INVALID_FIREBASE_TOKEN on 401 response', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 401,
statusText: 'Unauthorized',
json: () => Promise.resolve({ message: 'Invalid token' })
})
)
const store = useWorkspaceAuthStore()
const { error } = storeToRefs(store)
await expect(store.switchWorkspace('workspace-123')).rejects.toThrow(
WorkspaceAuthError
)
expect(error.value).toBeInstanceOf(WorkspaceAuthError)
expect((error.value as WorkspaceAuthError).code).toBe(
'INVALID_FIREBASE_TOKEN'
)
})
it('throws WorkspaceAuthError with code TOKEN_EXCHANGE_FAILED on other errors', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
json: () => Promise.resolve({ message: 'Server error' })
})
)
const store = useWorkspaceAuthStore()
const { error } = storeToRefs(store)
await expect(store.switchWorkspace('workspace-123')).rejects.toThrow(
WorkspaceAuthError
)
expect(error.value).toBeInstanceOf(WorkspaceAuthError)
expect((error.value as WorkspaceAuthError).code).toBe(
'TOKEN_EXCHANGE_FAILED'
)
})
it('sends correct request to API', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
await store.switchWorkspace('workspace-123')
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/api/auth/token',
{
method: 'POST',
headers: {
Authorization: 'Bearer firebase-token-xyz',
'Content-Type': 'application/json'
},
body: JSON.stringify({ workspace_id: 'workspace-123' })
}
)
})
})
describe('clearWorkspaceContext', () => {
it('clears all state refs', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
)
const store = useWorkspaceAuthStore()
const { currentWorkspace, workspaceToken, error, isAuthenticated } =
storeToRefs(store)
await store.switchWorkspace('workspace-123')
expect(isAuthenticated.value).toBe(true)
store.clearWorkspaceContext()
expect(currentWorkspace.value).toBeNull()
expect(workspaceToken.value).toBeNull()
expect(error.value).toBeNull()
expect(isAuthenticated.value).toBe(false)
})
it('clears sessionStorage', async () => {
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
JSON.stringify(mockWorkspaceWithRole)
)
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, 'some-token')
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT, '12345')
const store = useWorkspaceAuthStore()
store.clearWorkspaceContext()
expect(
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
).toBeNull()
expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)).toBeNull()
expect(
sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
).toBeNull()
})
})
describe('getWorkspaceAuthHeader', () => {
it('returns null when no workspace token', () => {
const store = useWorkspaceAuthStore()
const header = store.getWorkspaceAuthHeader()
expect(header).toBeNull()
})
it('returns proper Authorization header when workspace token exists', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
)
const store = useWorkspaceAuthStore()
await store.switchWorkspace('workspace-123')
const header = store.getWorkspaceAuthHeader()
expect(header).toEqual({
Authorization: 'Bearer workspace-token-abc'
})
})
})
describe('token refresh scheduling', () => {
it('schedules token refresh 5 minutes before expiry', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const expiresInMs = 3600 * 1000
const tokenResponseWithFutureExpiry = {
...mockTokenResponse,
expires_at: new Date(Date.now() + expiresInMs).toISOString()
}
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(tokenResponseWithFutureExpiry)
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
await store.switchWorkspace('workspace-123')
expect(mockFetch).toHaveBeenCalledTimes(1)
const refreshBufferMs = 5 * 60 * 1000
const refreshDelay = expiresInMs - refreshBufferMs
vi.advanceTimersByTime(refreshDelay - 1)
expect(mockFetch).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('clears context when refresh fails with ACCESS_DENIED', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const expiresInMs = 3600 * 1000
const tokenResponseWithFutureExpiry = {
...mockTokenResponse,
expires_at: new Date(Date.now() + expiresInMs).toISOString()
}
const mockFetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(tokenResponseWithFutureExpiry)
})
.mockResolvedValueOnce({
ok: false,
status: 403,
statusText: 'Forbidden',
json: () => Promise.resolve({ message: 'Access denied' })
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const { currentWorkspace, workspaceToken } = storeToRefs(store)
await store.switchWorkspace('workspace-123')
expect(workspaceToken.value).toBe('workspace-token-abc')
const refreshBufferMs = 5 * 60 * 1000
const refreshDelay = expiresInMs - refreshBufferMs
vi.advanceTimersByTime(refreshDelay)
await vi.waitFor(() => {
expect(currentWorkspace.value).toBeNull()
})
expect(workspaceToken.value).toBeNull()
})
})
describe('refreshToken', () => {
it('does nothing when no current workspace', async () => {
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
await store.refreshToken()
expect(mockFetch).not.toHaveBeenCalled()
})
it('refreshes token for current workspace', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const { workspaceToken } = storeToRefs(store)
await store.switchWorkspace('workspace-123')
expect(mockFetch).toHaveBeenCalledTimes(1)
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockTokenResponse,
token: 'refreshed-token'
})
})
await store.refreshToken()
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(workspaceToken.value).toBe('refreshed-token')
})
})
describe('isAuthenticated computed', () => {
it('returns true when both workspace and token are present', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
)
const store = useWorkspaceAuthStore()
const { isAuthenticated } = storeToRefs(store)
await store.switchWorkspace('workspace-123')
expect(isAuthenticated.value).toBe(true)
})
it('returns false when workspace is null', () => {
const store = useWorkspaceAuthStore()
const { isAuthenticated } = storeToRefs(store)
expect(isAuthenticated.value).toBe(false)
})
it('returns false when currentWorkspace is set but workspaceToken is null', async () => {
mockGetIdToken.mockResolvedValue(null)
const store = useWorkspaceAuthStore()
const { currentWorkspace, workspaceToken, isAuthenticated } =
storeToRefs(store)
currentWorkspace.value = mockWorkspaceWithRole
workspaceToken.value = null
expect(isAuthenticated.value).toBe(false)
})
})
describe('feature flag disabled', () => {
beforeEach(() => {
mockRemoteConfig.value.team_workspaces_enabled = false
})
afterEach(() => {
mockRemoteConfig.value.team_workspaces_enabled = true
})
it('initializeFromSession returns false when flag disabled', () => {
const futureExpiry = Date.now() + 3600 * 1000
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE,
JSON.stringify(mockWorkspaceWithRole)
)
sessionStorage.setItem(WORKSPACE_STORAGE_KEYS.TOKEN, 'valid-token')
sessionStorage.setItem(
WORKSPACE_STORAGE_KEYS.EXPIRES_AT,
futureExpiry.toString()
)
const store = useWorkspaceAuthStore()
const { currentWorkspace, workspaceToken } = storeToRefs(store)
const result = store.initializeFromSession()
expect(result).toBe(false)
expect(currentWorkspace.value).toBeNull()
expect(workspaceToken.value).toBeNull()
})
it('switchWorkspace is a no-op when flag disabled', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const { currentWorkspace, workspaceToken, isLoading } = storeToRefs(store)
await store.switchWorkspace('workspace-123')
expect(mockFetch).not.toHaveBeenCalled()
expect(currentWorkspace.value).toBeNull()
expect(workspaceToken.value).toBeNull()
expect(isLoading.value).toBe(false)
})
})
})

View File

@@ -0,0 +1,166 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
import type { WorkspaceWithRole } from '@/platform/auth/workspace/workspaceTypes'
const mockSwitchWorkspace = vi.hoisted(() => vi.fn())
const mockCurrentWorkspace = vi.hoisted(() => ({
value: null as WorkspaceWithRole | null
}))
vi.mock('@/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => ({
switchWorkspace: mockSwitchWorkspace
})
}))
vi.mock('pinia', () => ({
storeToRefs: () => ({
currentWorkspace: mockCurrentWorkspace
})
}))
const mockModifiedWorkflows = vi.hoisted(
() => [] as Array<{ isModified: boolean }>
)
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
get modifiedWorkflows() {
return mockModifiedWorkflows
}
})
}))
const mockConfirm = vi.hoisted(() => vi.fn())
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
confirm: mockConfirm
})
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
const mockReload = vi.fn()
describe('useWorkspaceSwitch', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCurrentWorkspace.value = {
id: 'workspace-1',
name: 'Test Workspace',
type: 'personal',
role: 'owner'
}
mockModifiedWorkflows.length = 0
vi.stubGlobal('location', { reload: mockReload })
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('hasUnsavedChanges', () => {
it('returns true when there are modified workflows', () => {
mockModifiedWorkflows.push({ isModified: true })
const { hasUnsavedChanges } = useWorkspaceSwitch()
expect(hasUnsavedChanges()).toBe(true)
})
it('returns true when multiple workflows are modified', () => {
mockModifiedWorkflows.push({ isModified: true }, { isModified: true })
const { hasUnsavedChanges } = useWorkspaceSwitch()
expect(hasUnsavedChanges()).toBe(true)
})
it('returns false when no workflows are modified', () => {
mockModifiedWorkflows.length = 0
const { hasUnsavedChanges } = useWorkspaceSwitch()
expect(hasUnsavedChanges()).toBe(false)
})
})
describe('switchWithConfirmation', () => {
it('returns true immediately if switching to the same workspace', async () => {
const { switchWithConfirmation } = useWorkspaceSwitch()
const result = await switchWithConfirmation('workspace-1')
expect(result).toBe(true)
expect(mockSwitchWorkspace).not.toHaveBeenCalled()
expect(mockConfirm).not.toHaveBeenCalled()
})
it('switches directly without dialog when no unsaved changes', async () => {
mockModifiedWorkflows.length = 0
mockSwitchWorkspace.mockResolvedValue(undefined)
const { switchWithConfirmation } = useWorkspaceSwitch()
const result = await switchWithConfirmation('workspace-2')
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 () => {
mockModifiedWorkflows.push({ isModified: true })
mockConfirm.mockResolvedValue(true)
mockSwitchWorkspace.mockResolvedValue(undefined)
const { switchWithConfirmation } = useWorkspaceSwitch()
await switchWithConfirmation('workspace-2')
expect(mockConfirm).toHaveBeenCalledWith({
title: 'workspace.unsavedChanges.title',
message: 'workspace.unsavedChanges.message',
type: 'dirtyClose'
})
})
it('returns false if user cancels the confirmation dialog', async () => {
mockModifiedWorkflows.push({ isModified: true })
mockConfirm.mockResolvedValue(false)
const { switchWithConfirmation } = useWorkspaceSwitch()
const result = await switchWithConfirmation('workspace-2')
expect(result).toBe(false)
expect(mockSwitchWorkspace).not.toHaveBeenCalled()
expect(mockReload).not.toHaveBeenCalled()
})
it('calls switchWorkspace and reloads page after user confirms', async () => {
mockModifiedWorkflows.push({ isModified: true })
mockConfirm.mockResolvedValue(true)
mockSwitchWorkspace.mockResolvedValue(undefined)
const { switchWithConfirmation } = useWorkspaceSwitch()
const result = await switchWithConfirmation('workspace-2')
expect(result).toBe(true)
expect(mockSwitchWorkspace).toHaveBeenCalledWith('workspace-2')
expect(mockReload).toHaveBeenCalled()
})
it('returns false if switchWorkspace throws an error', async () => {
mockModifiedWorkflows.length = 0
mockSwitchWorkspace.mockRejectedValue(new Error('Switch failed'))
const { switchWithConfirmation } = useWorkspaceSwitch()
const result = await switchWithConfirmation('workspace-2')
expect(result).toBe(false)
expect(mockReload).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,49 @@
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useWorkspaceAuthStore } from '@/stores/workspaceAuthStore'
export function useWorkspaceSwitch() {
const { t } = useI18n()
const workspaceAuthStore = useWorkspaceAuthStore()
const { currentWorkspace } = storeToRefs(workspaceAuthStore)
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()
function hasUnsavedChanges(): boolean {
return workflowStore.modifiedWorkflows.length > 0
}
async function switchWithConfirmation(workspaceId: string): Promise<boolean> {
if (currentWorkspace.value?.id === workspaceId) {
return true
}
if (hasUnsavedChanges()) {
const confirmed = await dialogService.confirm({
title: t('workspace.unsavedChanges.title'),
message: t('workspace.unsavedChanges.message'),
type: 'dirtyClose'
})
if (!confirmed) {
return false
}
}
try {
await workspaceAuthStore.switchWorkspace(workspaceId)
window.location.reload()
return true
} catch {
return false
}
}
return {
hasUnsavedChanges,
switchWithConfirmation
}
}

View File

@@ -0,0 +1,7 @@
export const WORKSPACE_STORAGE_KEYS = {
CURRENT_WORKSPACE: 'Comfy.Workspace.Current',
TOKEN: 'Comfy.Workspace.Token',
EXPIRES_AT: 'Comfy.Workspace.ExpiresAt'
} as const
export const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000

View File

@@ -0,0 +1,6 @@
export interface WorkspaceWithRole {
id: string
name: string
type: 'personal' | 'team'
role: 'owner' | 'member'
}

View File

@@ -42,4 +42,5 @@ export type RemoteConfig = {
huggingface_model_import_enabled?: boolean
linear_toggle_enabled?: boolean
async_model_upload_enabled?: boolean
team_workspaces_enabled?: boolean
}