diff --git a/src/components/TopMenuSection.vue b/src/components/TopMenuSection.vue index 28f2411ea..dc3657b39 100644 --- a/src/components/TopMenuSection.vue +++ b/src/components/TopMenuSection.vue @@ -59,6 +59,7 @@ {{ queuedCount }} + +import { computed } from 'vue' +import { useI18n } from 'vue-i18n' + +import Button from '@/components/ui/button/Button.vue' +import type { WorkspaceRole, WorkspaceWithRole } from '@/types/workspaceTypes' +import { cn } from '@/utils/tailwindUtil' + +const { workspaces, currentWorkspaceId, isLoading } = defineProps<{ + workspaces: WorkspaceWithRole[] + currentWorkspaceId: string | null + isLoading: boolean +}>() + +const emit = defineEmits<{ + select: [workspaceId: string] +}>() + +const { t } = useI18n() + +const sortedWorkspaces = computed(() => { + return [...workspaces].sort((a, b) => { + if (a.role === 'owner' && b.role !== 'owner') return -1 + if (a.role !== 'owner' && b.role === 'owner') return 1 + return a.name.localeCompare(b.name) + }) +}) + +function getRoleBadgeClass(role: WorkspaceRole): string { + switch (role) { + case 'owner': + return 'bg-emerald-500/20 text-emerald-400' + case 'member': + return 'bg-blue-500/20 text-blue-400' + } +} + +function handleWorkspaceClick(workspace: WorkspaceWithRole): void { + if (workspace.id !== currentWorkspaceId) { + emit('select', workspace.id) + } +} + + + diff --git a/src/platform/auth/workspace/components/WorkspaceSwitcher.vue b/src/platform/auth/workspace/components/WorkspaceSwitcher.vue new file mode 100644 index 000000000..80846371c --- /dev/null +++ b/src/platform/auth/workspace/components/WorkspaceSwitcher.vue @@ -0,0 +1,117 @@ + + + diff --git a/src/platform/auth/workspace/useWorkspaceAuth.test.ts b/src/platform/auth/workspace/useWorkspaceAuth.test.ts new file mode 100644 index 000000000..687075091 --- /dev/null +++ b/src/platform/auth/workspace/useWorkspaceAuth.test.ts @@ -0,0 +1,584 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useWorkspaceAuth, WorkspaceAuthError } from './useWorkspaceAuth' + +const mockGetIdToken = vi.fn() + +vi.mock('@/stores/firebaseAuthStore', () => ({ + useFirebaseAuthStore: () => ({ + getIdToken: mockGetIdToken + }) +})) + +vi.mock('@/config/comfyApi', () => ({ + getComfyApiBaseUrl: () => 'https://api.example.com' +})) + +vi.mock('@/i18n', () => ({ + t: (key: string) => key +})) + +const STORAGE_KEYS = { + CURRENT_WORKSPACE: 'Comfy.Workspace.Current', + TOKEN: 'Comfy.Workspace.Token', + EXPIRES_AT: 'Comfy.Workspace.ExpiresAt' +} + +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('useWorkspaceAuth', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + vi.useFakeTimers() + sessionStorage.clear() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('initial state', () => { + it('currentWorkspace is null initially', () => { + const { currentWorkspace } = useWorkspaceAuth() + expect(currentWorkspace.value).toBeNull() + }) + + it('workspaceToken is null initially', () => { + const { workspaceToken } = useWorkspaceAuth() + expect(workspaceToken.value).toBeNull() + }) + + it('isAuthenticated is false initially', () => { + const { isAuthenticated } = useWorkspaceAuth() + expect(isAuthenticated.value).toBe(false) + }) + + it('isLoading is false initially', () => { + const { isLoading } = useWorkspaceAuth() + expect(isLoading.value).toBe(false) + }) + + it('error is null initially', () => { + const { error } = useWorkspaceAuth() + 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( + STORAGE_KEYS.CURRENT_WORKSPACE, + JSON.stringify(mockWorkspaceWithRole) + ) + sessionStorage.setItem(STORAGE_KEYS.TOKEN, 'valid-token') + sessionStorage.setItem(STORAGE_KEYS.EXPIRES_AT, futureExpiry.toString()) + + const { initializeFromSession, currentWorkspace, workspaceToken } = + useWorkspaceAuth() + + const result = initializeFromSession() + + expect(result).toBe(true) + expect(currentWorkspace.value).toEqual(mockWorkspaceWithRole) + expect(workspaceToken.value).toBe('valid-token') + }) + + it('returns false when sessionStorage is empty', () => { + const { initializeFromSession } = useWorkspaceAuth() + + const result = initializeFromSession() + + expect(result).toBe(false) + }) + + it('returns false and clears storage when token is expired', () => { + const pastExpiry = Date.now() - 1000 + sessionStorage.setItem( + STORAGE_KEYS.CURRENT_WORKSPACE, + JSON.stringify(mockWorkspaceWithRole) + ) + sessionStorage.setItem(STORAGE_KEYS.TOKEN, 'expired-token') + sessionStorage.setItem(STORAGE_KEYS.EXPIRES_AT, pastExpiry.toString()) + + const { initializeFromSession } = useWorkspaceAuth() + + const result = initializeFromSession() + + expect(result).toBe(false) + expect(sessionStorage.getItem(STORAGE_KEYS.CURRENT_WORKSPACE)).toBeNull() + expect(sessionStorage.getItem(STORAGE_KEYS.TOKEN)).toBeNull() + expect(sessionStorage.getItem(STORAGE_KEYS.EXPIRES_AT)).toBeNull() + }) + + it('returns false and clears storage when data is malformed', () => { + sessionStorage.setItem(STORAGE_KEYS.CURRENT_WORKSPACE, 'invalid-json{') + sessionStorage.setItem(STORAGE_KEYS.TOKEN, 'some-token') + sessionStorage.setItem(STORAGE_KEYS.EXPIRES_AT, 'not-a-number') + + const { initializeFromSession } = useWorkspaceAuth() + + const result = initializeFromSession() + + expect(result).toBe(false) + expect(sessionStorage.getItem(STORAGE_KEYS.CURRENT_WORKSPACE)).toBeNull() + expect(sessionStorage.getItem(STORAGE_KEYS.TOKEN)).toBeNull() + expect(sessionStorage.getItem(STORAGE_KEYS.EXPIRES_AT)).toBeNull() + }) + + it('returns false when partial session data exists (missing token)', () => { + sessionStorage.setItem( + STORAGE_KEYS.CURRENT_WORKSPACE, + JSON.stringify(mockWorkspaceWithRole) + ) + sessionStorage.setItem( + STORAGE_KEYS.EXPIRES_AT, + (Date.now() + 3600 * 1000).toString() + ) + + const { initializeFromSession } = useWorkspaceAuth() + + const result = 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 { + switchWorkspace, + currentWorkspace, + workspaceToken, + isAuthenticated + } = useWorkspaceAuth() + + await 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 { switchWorkspace } = useWorkspaceAuth() + + await switchWorkspace('workspace-123') + + expect(sessionStorage.getItem(STORAGE_KEYS.CURRENT_WORKSPACE)).toBe( + JSON.stringify(mockWorkspaceWithRole) + ) + expect(sessionStorage.getItem(STORAGE_KEYS.TOKEN)).toBe( + 'workspace-token-abc' + ) + expect(sessionStorage.getItem(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 { switchWorkspace, isLoading } = useWorkspaceAuth() + + const switchPromise = 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 { switchWorkspace, error } = useWorkspaceAuth() + + await expect(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 { switchWorkspace, error } = useWorkspaceAuth() + + await expect(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 { switchWorkspace, error } = useWorkspaceAuth() + + await expect(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 { switchWorkspace, error } = useWorkspaceAuth() + + await expect(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 { switchWorkspace, error } = useWorkspaceAuth() + + await expect(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 { switchWorkspace } = useWorkspaceAuth() + + await 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 { + switchWorkspace, + clearWorkspaceContext, + currentWorkspace, + workspaceToken, + error, + isAuthenticated + } = useWorkspaceAuth() + + await switchWorkspace('workspace-123') + expect(isAuthenticated.value).toBe(true) + + clearWorkspaceContext() + + expect(currentWorkspace.value).toBeNull() + expect(workspaceToken.value).toBeNull() + expect(error.value).toBeNull() + expect(isAuthenticated.value).toBe(false) + }) + + it('clears sessionStorage', async () => { + sessionStorage.setItem( + STORAGE_KEYS.CURRENT_WORKSPACE, + JSON.stringify(mockWorkspaceWithRole) + ) + sessionStorage.setItem(STORAGE_KEYS.TOKEN, 'some-token') + sessionStorage.setItem(STORAGE_KEYS.EXPIRES_AT, '12345') + + const { clearWorkspaceContext } = useWorkspaceAuth() + + clearWorkspaceContext() + + expect(sessionStorage.getItem(STORAGE_KEYS.CURRENT_WORKSPACE)).toBeNull() + expect(sessionStorage.getItem(STORAGE_KEYS.TOKEN)).toBeNull() + expect(sessionStorage.getItem(STORAGE_KEYS.EXPIRES_AT)).toBeNull() + }) + }) + + describe('getWorkspaceAuthHeader', () => { + it('returns null when no workspace token', () => { + const { getWorkspaceAuthHeader } = useWorkspaceAuth() + + const header = 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 { switchWorkspace, getWorkspaceAuthHeader } = useWorkspaceAuth() + + await switchWorkspace('workspace-123') + const header = 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 { switchWorkspace } = useWorkspaceAuth() + + await switchWorkspace('workspace-123') + + expect(mockFetch).toHaveBeenCalledTimes(1) + + const refreshBufferMs = 5 * 60 * 1000 + const refreshDelay = expiresInMs - refreshBufferMs + + vi.advanceTimersByTime(refreshDelay - 1) + expect(mockFetch).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(1) + await Promise.resolve() + + 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 { switchWorkspace, currentWorkspace, workspaceToken } = + useWorkspaceAuth() + + await 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 { refreshToken } = useWorkspaceAuth() + + await 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 { switchWorkspace, refreshToken, workspaceToken } = + useWorkspaceAuth() + + await switchWorkspace('workspace-123') + expect(mockFetch).toHaveBeenCalledTimes(1) + + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + ...mockTokenResponse, + token: 'refreshed-token' + }) + }) + + await 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 { switchWorkspace, isAuthenticated } = useWorkspaceAuth() + + await switchWorkspace('workspace-123') + + expect(isAuthenticated.value).toBe(true) + }) + + it('returns false when workspace is null', () => { + const { isAuthenticated } = useWorkspaceAuth() + + expect(isAuthenticated.value).toBe(false) + }) + }) +}) diff --git a/src/platform/auth/workspace/useWorkspaceAuth.ts b/src/platform/auth/workspace/useWorkspaceAuth.ts new file mode 100644 index 000000000..a91b257c3 --- /dev/null +++ b/src/platform/auth/workspace/useWorkspaceAuth.ts @@ -0,0 +1,243 @@ +import { computed, onUnmounted, ref, shallowRef } from 'vue' + +import { getComfyApiBaseUrl } from '@/config/comfyApi' +import { t } from '@/i18n' +import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' +import type { AuthHeader } from '@/types/authTypes' +import type { + WorkspaceTokenResponse, + WorkspaceWithRole +} from '@/types/workspaceTypes' + +const STORAGE_KEYS = { + CURRENT_WORKSPACE: 'Comfy.Workspace.Current', + TOKEN: 'Comfy.Workspace.Token', + EXPIRES_AT: 'Comfy.Workspace.ExpiresAt' +} as const + +const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000 + +export class WorkspaceAuthError extends Error { + constructor( + message: string, + public readonly code?: string + ) { + super(message) + this.name = 'WorkspaceAuthError' + } +} + +export function useWorkspaceAuth() { + const firebaseAuthStore = useFirebaseAuthStore() + + const currentWorkspace = shallowRef(null) + const workspaceToken = ref(null) + const isLoading = ref(false) + const error = ref(null) + + let refreshTimer: ReturnType | null = null + + const isAuthenticated = computed( + () => currentWorkspace.value !== null && workspaceToken.value !== null + ) + + function scheduleTokenRefresh(expiresAt: number): void { + clearRefreshTimer() + const now = Date.now() + const refreshAt = expiresAt - TOKEN_REFRESH_BUFFER_MS + const delay = Math.max(0, refreshAt - now) + + refreshTimer = setTimeout(() => { + void refreshToken() + }, delay) + } + + function clearRefreshTimer(): void { + if (refreshTimer !== null) { + clearTimeout(refreshTimer) + refreshTimer = null + } + } + + function persistToSession( + workspace: WorkspaceWithRole, + token: string, + expiresAt: number + ): void { + try { + sessionStorage.setItem( + STORAGE_KEYS.CURRENT_WORKSPACE, + JSON.stringify(workspace) + ) + sessionStorage.setItem(STORAGE_KEYS.TOKEN, token) + sessionStorage.setItem(STORAGE_KEYS.EXPIRES_AT, expiresAt.toString()) + } catch { + console.warn('Failed to persist workspace context to sessionStorage') + } + } + + function clearSessionStorage(): void { + try { + sessionStorage.removeItem(STORAGE_KEYS.CURRENT_WORKSPACE) + sessionStorage.removeItem(STORAGE_KEYS.TOKEN) + sessionStorage.removeItem(STORAGE_KEYS.EXPIRES_AT) + } catch { + console.warn('Failed to clear workspace context from sessionStorage') + } + } + + function initializeFromSession(): boolean { + try { + const workspaceJson = sessionStorage.getItem( + STORAGE_KEYS.CURRENT_WORKSPACE + ) + const token = sessionStorage.getItem(STORAGE_KEYS.TOKEN) + const expiresAtStr = sessionStorage.getItem(STORAGE_KEYS.EXPIRES_AT) + + if (!workspaceJson || !token || !expiresAtStr) { + return false + } + + const expiresAt = parseInt(expiresAtStr, 10) + if (isNaN(expiresAt) || expiresAt <= Date.now()) { + clearSessionStorage() + return false + } + + const workspace = JSON.parse(workspaceJson) as WorkspaceWithRole + currentWorkspace.value = workspace + workspaceToken.value = token + error.value = null + + scheduleTokenRefresh(expiresAt) + return true + } catch { + clearSessionStorage() + return false + } + } + + async function switchWorkspace(workspaceId: string): Promise { + isLoading.value = true + error.value = null + + try { + const firebaseToken = await firebaseAuthStore.getIdToken() + if (!firebaseToken) { + throw new WorkspaceAuthError( + t('workspaceAuth.errors.notAuthenticated'), + 'NOT_AUTHENTICATED' + ) + } + + const response = await fetch(`${getComfyApiBaseUrl()}/api/auth/token`, { + method: 'POST', + headers: { + Authorization: `Bearer ${firebaseToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ workspace_id: workspaceId }) + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + const message = errorData.message || response.statusText + + if (response.status === 401) { + throw new WorkspaceAuthError( + t('workspaceAuth.errors.invalidFirebaseToken'), + 'INVALID_FIREBASE_TOKEN' + ) + } + if (response.status === 403) { + throw new WorkspaceAuthError( + t('workspaceAuth.errors.accessDenied'), + 'ACCESS_DENIED' + ) + } + if (response.status === 404) { + throw new WorkspaceAuthError( + t('workspaceAuth.errors.workspaceNotFound'), + 'WORKSPACE_NOT_FOUND' + ) + } + + throw new WorkspaceAuthError( + t('workspaceAuth.errors.tokenExchangeFailed', { error: message }), + 'TOKEN_EXCHANGE_FAILED' + ) + } + + const data: WorkspaceTokenResponse = await response.json() + const expiresAt = new Date(data.expires_at).getTime() + + const workspaceWithRole: WorkspaceWithRole = { + ...data.workspace, + role: data.role + } + + currentWorkspace.value = workspaceWithRole + workspaceToken.value = data.token + + persistToSession(workspaceWithRole, data.token, expiresAt) + scheduleTokenRefresh(expiresAt) + } catch (err) { + error.value = err instanceof Error ? err : new Error(String(err)) + throw error.value + } finally { + isLoading.value = false + } + } + + async function refreshToken(): Promise { + if (!currentWorkspace.value) { + return + } + + try { + await switchWorkspace(currentWorkspace.value.id) + } catch (err) { + console.error('Failed to refresh workspace token:', err) + if ( + err instanceof WorkspaceAuthError && + (err.code === 'ACCESS_DENIED' || err.code === 'WORKSPACE_NOT_FOUND') + ) { + clearWorkspaceContext() + } + } + } + + function getWorkspaceAuthHeader(): AuthHeader | null { + if (!workspaceToken.value) { + return null + } + return { + Authorization: `Bearer ${workspaceToken.value}` + } + } + + function clearWorkspaceContext(): void { + clearRefreshTimer() + currentWorkspace.value = null + workspaceToken.value = null + error.value = null + clearSessionStorage() + } + + onUnmounted(() => { + clearRefreshTimer() + }) + + return { + currentWorkspace, + workspaceToken, + isLoading, + error, + isAuthenticated, + initializeFromSession, + switchWorkspace, + refreshToken, + getWorkspaceAuthHeader, + clearWorkspaceContext + } +} diff --git a/src/platform/auth/workspace/useWorkspaceSwitch.test.ts b/src/platform/auth/workspace/useWorkspaceSwitch.test.ts new file mode 100644 index 000000000..0f6fcdfcc --- /dev/null +++ b/src/platform/auth/workspace/useWorkspaceSwitch.test.ts @@ -0,0 +1,155 @@ +import { ref } from 'vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch' + +const mockSwitchWorkspace = vi.fn() +const mockCurrentWorkspace = ref<{ id: string } | null>(null) + +vi.mock('@/platform/auth/workspace/useWorkspaceAuth', () => ({ + useWorkspaceAuth: () => ({ + currentWorkspace: mockCurrentWorkspace, + switchWorkspace: mockSwitchWorkspace + }) +})) + +const mockActiveWorkflow = ref<{ isModified: boolean } | null>(null) + +vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ + useWorkflowStore: () => ({ + activeWorkflow: mockActiveWorkflow.value + }) +})) + +const mockConfirm = 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' } + mockActiveWorkflow.value = null + Object.defineProperty(window, 'location', { + value: { reload: mockReload }, + writable: true + }) + }) + + describe('hasUnsavedChanges', () => { + it('returns true when activeWorkflow.isModified is true', () => { + mockActiveWorkflow.value = { isModified: true } + const { hasUnsavedChanges } = useWorkspaceSwitch() + + expect(hasUnsavedChanges()).toBe(true) + }) + + it('returns false when activeWorkflow.isModified is false', () => { + mockActiveWorkflow.value = { isModified: false } + const { hasUnsavedChanges } = useWorkspaceSwitch() + + expect(hasUnsavedChanges()).toBe(false) + }) + + it('returns false when activeWorkflow is null', () => { + mockActiveWorkflow.value = null + const { hasUnsavedChanges } = useWorkspaceSwitch() + + expect(hasUnsavedChanges()).toBe(false) + }) + }) + + describe('switchWithConfirmation', () => { + it('returns true immediately if switching to the same workspace', async () => { + mockCurrentWorkspace.value = { id: 'workspace-1' } + 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 () => { + mockCurrentWorkspace.value = { id: 'workspace-1' } + mockActiveWorkflow.value = { isModified: false } + 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 () => { + mockCurrentWorkspace.value = { id: 'workspace-1' } + mockActiveWorkflow.value = { 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 () => { + mockCurrentWorkspace.value = { id: 'workspace-1' } + mockActiveWorkflow.value = { 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 () => { + mockCurrentWorkspace.value = { id: 'workspace-1' } + mockActiveWorkflow.value = { 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 () => { + mockCurrentWorkspace.value = { id: 'workspace-1' } + mockActiveWorkflow.value = { isModified: false } + mockSwitchWorkspace.mockRejectedValue(new Error('Switch failed')) + const { switchWithConfirmation } = useWorkspaceSwitch() + + const result = await switchWithConfirmation('workspace-2') + + expect(result).toBe(false) + expect(mockReload).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/platform/auth/workspace/useWorkspaceSwitch.ts b/src/platform/auth/workspace/useWorkspaceSwitch.ts new file mode 100644 index 000000000..17e34c208 --- /dev/null +++ b/src/platform/auth/workspace/useWorkspaceSwitch.ts @@ -0,0 +1,47 @@ +import { useI18n } from 'vue-i18n' + +import { useWorkspaceAuth } from '@/platform/auth/workspace/useWorkspaceAuth' +import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' +import { useDialogService } from '@/services/dialogService' + +export function useWorkspaceSwitch() { + const { t } = useI18n() + const workspaceAuth = useWorkspaceAuth() + const workflowStore = useWorkflowStore() + const dialogService = useDialogService() + + function hasUnsavedChanges(): boolean { + return workflowStore.activeWorkflow?.isModified ?? false + } + + async function switchWithConfirmation(workspaceId: string): Promise { + if (workspaceAuth.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 workspaceAuth.switchWorkspace(workspaceId) + window.location.reload() + return true + } catch { + return false + } + } + + return { + hasUnsavedChanges, + switchWithConfirmation + } +} diff --git a/src/stores/firebaseAuthStore.ts b/src/stores/firebaseAuthStore.ts index 9f3039887..52c1dd872 100644 --- a/src/stores/firebaseAuthStore.ts +++ b/src/stores/firebaseAuthStore.ts @@ -152,16 +152,28 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { /** * Retrieves the appropriate authentication header for API requests. * Checks for authentication in the following order: - * 1. Firebase authentication token (if user is logged in) - * 2. API key (if stored in the browser's credential manager) + * 1. Workspace token (if user has active workspace context) + * 2. Firebase authentication token (if user is logged in) + * 3. API key (if stored in the browser's credential manager) * * @returns {Promise} - * - A LoggedInAuthHeader with Bearer token if Firebase authenticated + * - A LoggedInAuthHeader with Bearer token (workspace or Firebase) * - An ApiKeyAuthHeader with X-API-KEY if API key exists - * - null if neither authentication method is available + * - null if no authentication method is available */ const getAuthHeader = async (): Promise => { - // If available, set header with JWT used to identify the user to Firebase service + const workspaceToken = sessionStorage.getItem('Comfy.Workspace.Token') + const expiresAt = sessionStorage.getItem('Comfy.Workspace.ExpiresAt') + + if (workspaceToken && expiresAt) { + const expiryTime = parseInt(expiresAt, 10) + if (Date.now() < expiryTime) { + return { + Authorization: `Bearer ${workspaceToken}` + } + } + } + const token = await getIdToken() if (token) { return { @@ -169,7 +181,6 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { } } - // If not authenticated with Firebase, try falling back to API key if available return useApiKeyAuthStore().getAuthHeader() } diff --git a/src/types/workspaceTypes.ts b/src/types/workspaceTypes.ts new file mode 100644 index 000000000..e168c095e --- /dev/null +++ b/src/types/workspaceTypes.ts @@ -0,0 +1,24 @@ +export type WorkspaceRole = 'owner' | 'member' + +export interface WorkspaceWithRole { + id: string + name: string + type: 'personal' | 'team' + role: WorkspaceRole +} + +export interface WorkspaceTokenResponse { + token: string + expires_at: string + workspace: { + id: string + name: string + type: 'personal' | 'team' + } + role: WorkspaceRole + permissions: string[] +} + +export interface ListWorkspacesResponse { + workspaces: WorkspaceWithRole[] +}