From bc698fb746b4479905a24768473528812dc67bfd Mon Sep 17 00:00:00 2001 From: --list <18093452+simula-r@users.noreply.github.com> Date: Sat, 17 Jan 2026 00:30:05 -0800 Subject: [PATCH] feat: workspace switcher and misc --- src/components/common/WorkspaceProfilePic.vue | 2 +- .../content/setting/MembersPanelContent.vue | 67 +- .../content/setting/WorkspacePanelContent.vue | 28 +- .../content/setting/WorkspaceSidebarItem.vue | 8 +- .../CreateWorkspaceDialogContent.vue | 19 +- .../DeleteWorkspaceDialogContent.vue | 31 +- .../workspace/EditWorkspaceDialogContent.vue | 26 +- .../workspace/InviteMemberDialogContent.vue | 6 +- .../workspace/LeaveWorkspaceDialogContent.vue | 22 +- .../workspace/RemoveMemberDialogContent.vue | 8 +- .../workspace/RevokeInviteDialogContent.vue | 8 +- src/components/topbar/CurrentUserButton.vue | 5 +- .../topbar/CurrentUserPopover.test.ts | 21 + src/components/topbar/CurrentUserPopover.vue | 49 +- .../topbar/WorkspaceSwitcherPopover.vue | 169 ++-- src/locales/en/main.json | 27 +- .../auth/workspace/useWorkspaceAuth.test.ts | 670 ---------------- .../auth/workspace/useWorkspaceSwitch.test.ts | 19 +- .../auth/workspace/useWorkspaceSwitch.ts | 12 +- .../auth/workspace/workspaceConstants.ts | 5 +- .../components/SubscriptionPanel.test.ts | 25 + .../components/SubscriptionPanelContent.vue | 16 +- .../navigation/preservedQueryNamespaces.ts | 3 +- .../WORKSPACE_IMPLEMENTATION_SPEC.md | 753 ++++++++++++++++++ src/platform/workspace/api/workspaceApi.ts | 215 +++-- .../composables/useInviteUrlLoader.test.ts | 235 ++++++ .../composables/useInviteUrlLoader.ts | 84 ++ .../workspace/composables/useWorkspace.ts | 605 -------------- .../workspace/composables/useWorkspaceUI.ts | 177 ++++ .../workspace/services/sessionManager.ts | 98 +++ .../workspace/stores/workspaceStore.ts | 619 ++++++++++++++ src/router.ts | 30 + src/services/dialogService.ts | 35 +- src/stores/workspaceAuthStore.ts | 373 --------- 34 files changed, 2581 insertions(+), 1889 deletions(-) delete mode 100644 src/platform/auth/workspace/useWorkspaceAuth.test.ts create mode 100644 src/platform/workspace/WORKSPACE_IMPLEMENTATION_SPEC.md create mode 100644 src/platform/workspace/composables/useInviteUrlLoader.test.ts create mode 100644 src/platform/workspace/composables/useInviteUrlLoader.ts delete mode 100644 src/platform/workspace/composables/useWorkspace.ts create mode 100644 src/platform/workspace/composables/useWorkspaceUI.ts create mode 100644 src/platform/workspace/services/sessionManager.ts create mode 100644 src/platform/workspace/stores/workspaceStore.ts delete mode 100644 src/stores/workspaceAuthStore.ts diff --git a/src/components/common/WorkspaceProfilePic.vue b/src/components/common/WorkspaceProfilePic.vue index b8ecd6108..642317267 100644 --- a/src/components/common/WorkspaceProfilePic.vue +++ b/src/components/common/WorkspaceProfilePic.vue @@ -17,7 +17,7 @@ const { workspaceName } = defineProps<{ workspaceName: string }>() -const letter = computed(() => workspaceName.charAt(0).toUpperCase()) +const letter = computed(() => workspaceName?.charAt(0)?.toUpperCase() ?? '?') const gradient = computed(() => { const seed = letter.value.charCodeAt(0) diff --git a/src/components/dialog/content/setting/MembersPanelContent.vue b/src/components/dialog/content/setting/MembersPanelContent.vue index 305940d8a..9e95b97da 100644 --- a/src/components/dialog/content/setting/MembersPanelContent.vue +++ b/src/components/dialog/content/setting/MembersPanelContent.vue @@ -237,12 +237,12 @@ class="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary-background" > - {{ invite.name.charAt(0).toUpperCase() }} + {{ getInviteInitial(invite.email) }}
- {{ invite.name }} + {{ getInviteDisplayName(invite.email) }} {{ invite.email }} @@ -310,7 +310,9 @@ diff --git a/src/components/dialog/content/setting/WorkspacePanelContent.vue b/src/components/dialog/content/setting/WorkspacePanelContent.vue index 8375edb69..7ffc057ef 100644 --- a/src/components/dialog/content/setting/WorkspacePanelContent.vue +++ b/src/components/dialog/content/setting/WorkspacePanelContent.vue @@ -85,6 +85,7 @@ diff --git a/src/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue b/src/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue index 0c317c6fe..0ec8ceb5d 100644 --- a/src/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue +++ b/src/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue @@ -60,16 +60,20 @@ diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 4137e5dea..68d5cd19c 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2147,7 +2147,8 @@ }, "deleteDialog": { "title": "Delete this workspace?", - "message": "Any unused credits or unsaved assets will be lost. This action cannot be undone." + "message": "Any unused credits or unsaved assets will be lost. This action cannot be undone.", + "messageWithName": "Delete \"{name}\"? Any unused credits or unsaved assets will be lost. This action cannot be undone." }, "removeMemberDialog": { "title": "Remove this member?", @@ -2183,7 +2184,24 @@ "title": "Workspace created", "message": "Subscribe to a plan, invite teammates, and start collaborating.", "subscribe": "Subscribe" - } + }, + "workspaceUpdated": { + "title": "Workspace updated", + "message": "Workspace details have been saved." + }, + "workspaceDeleted": { + "title": "Workspace deleted", + "message": "The workspace has been permanently deleted." + }, + "workspaceLeft": { + "title": "Left workspace", + "message": "You have left the workspace." + }, + "failedToUpdateWorkspace": "Failed to update workspace", + "failedToCreateWorkspace": "Failed to create workspace", + "failedToDeleteWorkspace": "Failed to delete workspace", + "failedToLeaveWorkspace": "Failed to leave workspace", + "failedToFetchWorkspaces": "Failed to load workspaces" } }, "workspaceSwitcher": { @@ -2710,7 +2728,10 @@ "unsavedChanges": { "title": "Unsaved Changes", "message": "You have unsaved changes. Do you want to discard them and switch workspaces?" - } + }, + "inviteAccepted": "Invite Accepted", + "addedToWorkspace": "You have been added to {workspaceName}", + "inviteFailed": "Failed to Accept Invite" }, "workspaceAuth": { "errors": { diff --git a/src/platform/auth/workspace/useWorkspaceAuth.test.ts b/src/platform/auth/workspace/useWorkspaceAuth.test.ts deleted file mode 100644 index 68f5a198c..000000000 --- a/src/platform/auth/workspace/useWorkspaceAuth.test.ts +++ /dev/null @@ -1,670 +0,0 @@ -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) - }) - }) -}) diff --git a/src/platform/auth/workspace/useWorkspaceSwitch.test.ts b/src/platform/auth/workspace/useWorkspaceSwitch.test.ts index ae71937b2..45ebe981d 100644 --- a/src/platform/auth/workspace/useWorkspaceSwitch.test.ts +++ b/src/platform/auth/workspace/useWorkspaceSwitch.test.ts @@ -4,19 +4,19 @@ 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(() => ({ +const mockActiveWorkspace = vi.hoisted(() => ({ value: null as WorkspaceWithRole | null })) -vi.mock('@/stores/workspaceAuthStore', () => ({ - useWorkspaceAuthStore: () => ({ +vi.mock('@/platform/workspace/stores/workspaceStore', () => ({ + useWorkspaceStore: () => ({ switchWorkspace: mockSwitchWorkspace }) })) vi.mock('pinia', () => ({ storeToRefs: () => ({ - currentWorkspace: mockCurrentWorkspace + activeWorkspace: mockActiveWorkspace }) })) @@ -46,19 +46,16 @@ vi.mock('vue-i18n', () => ({ }) })) -const mockReload = vi.fn() - describe('useWorkspaceSwitch', () => { beforeEach(() => { vi.clearAllMocks() - mockCurrentWorkspace.value = { + mockActiveWorkspace.value = { id: 'workspace-1', name: 'Test Workspace', type: 'personal', role: 'owner' } mockModifiedWorkflows.length = 0 - vi.stubGlobal('location', { reload: mockReload }) }) afterEach(() => { @@ -109,7 +106,6 @@ describe('useWorkspaceSwitch', () => { expect(result).toBe(true) expect(mockConfirm).not.toHaveBeenCalled() expect(mockSwitchWorkspace).toHaveBeenCalledWith('workspace-2') - expect(mockReload).toHaveBeenCalled() }) it('shows confirmation dialog when there are unsaved changes', async () => { @@ -136,10 +132,9 @@ describe('useWorkspaceSwitch', () => { expect(result).toBe(false) expect(mockSwitchWorkspace).not.toHaveBeenCalled() - expect(mockReload).not.toHaveBeenCalled() }) - it('calls switchWorkspace and reloads page after user confirms', async () => { + it('calls switchWorkspace after user confirms', async () => { mockModifiedWorkflows.push({ isModified: true }) mockConfirm.mockResolvedValue(true) mockSwitchWorkspace.mockResolvedValue(undefined) @@ -149,7 +144,6 @@ describe('useWorkspaceSwitch', () => { expect(result).toBe(true) expect(mockSwitchWorkspace).toHaveBeenCalledWith('workspace-2') - expect(mockReload).toHaveBeenCalled() }) it('returns false if switchWorkspace throws an error', async () => { @@ -160,7 +154,6 @@ describe('useWorkspaceSwitch', () => { const result = await switchWithConfirmation('workspace-2') expect(result).toBe(false) - expect(mockReload).not.toHaveBeenCalled() }) }) }) diff --git a/src/platform/auth/workspace/useWorkspaceSwitch.ts b/src/platform/auth/workspace/useWorkspaceSwitch.ts index 6fb970d7f..5303bdba0 100644 --- a/src/platform/auth/workspace/useWorkspaceSwitch.ts +++ b/src/platform/auth/workspace/useWorkspaceSwitch.ts @@ -2,13 +2,13 @@ import { storeToRefs } from 'pinia' import { useI18n } from 'vue-i18n' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' +import { useWorkspaceStore } from '@/platform/workspace/stores/workspaceStore' import { useDialogService } from '@/services/dialogService' -import { useWorkspaceAuthStore } from '@/stores/workspaceAuthStore' export function useWorkspaceSwitch() { const { t } = useI18n() - const workspaceAuthStore = useWorkspaceAuthStore() - const { currentWorkspace } = storeToRefs(workspaceAuthStore) + const workspaceStore = useWorkspaceStore() + const { activeWorkspace } = storeToRefs(workspaceStore) const workflowStore = useWorkflowStore() const dialogService = useDialogService() @@ -17,7 +17,7 @@ export function useWorkspaceSwitch() { } async function switchWithConfirmation(workspaceId: string): Promise { - if (currentWorkspace.value?.id === workspaceId) { + if (activeWorkspace.value?.id === workspaceId) { return true } @@ -34,8 +34,8 @@ export function useWorkspaceSwitch() { } try { - await workspaceAuthStore.switchWorkspace(workspaceId) - window.location.reload() + await workspaceStore.switchWorkspace(workspaceId) + // Note: switchWorkspace triggers page reload internally return true } catch { return false diff --git a/src/platform/auth/workspace/workspaceConstants.ts b/src/platform/auth/workspace/workspaceConstants.ts index cc28d1f47..b56c1644d 100644 --- a/src/platform/auth/workspace/workspaceConstants.ts +++ b/src/platform/auth/workspace/workspaceConstants.ts @@ -1,7 +1,10 @@ export const WORKSPACE_STORAGE_KEYS = { + // sessionStorage keys (cleared on browser close) CURRENT_WORKSPACE: 'Comfy.Workspace.Current', TOKEN: 'Comfy.Workspace.Token', - EXPIRES_AT: 'Comfy.Workspace.ExpiresAt' + EXPIRES_AT: 'Comfy.Workspace.ExpiresAt', + // localStorage key (persists across browser sessions) + LAST_WORKSPACE_ID: 'Comfy.Workspace.LastWorkspaceId' } as const export const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000 diff --git a/src/platform/cloud/subscription/components/SubscriptionPanel.test.ts b/src/platform/cloud/subscription/components/SubscriptionPanel.test.ts index 04cbbbbed..9eb7e4574 100644 --- a/src/platform/cloud/subscription/components/SubscriptionPanel.test.ts +++ b/src/platform/cloud/subscription/components/SubscriptionPanel.test.ts @@ -90,6 +90,31 @@ vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({ })) })) +// Mock toast store (needed by useWorkspace -> useInviteUrlLoader) +vi.mock('@/platform/updates/common/toastStore', () => ({ + useToastStore: vi.fn(() => ({ + add: vi.fn() + })) +})) + +// Mock useWorkspace composable +vi.mock('@/platform/workspace/composables/useWorkspace', () => ({ + useWorkspace: vi.fn(() => ({ + workspaceName: { value: 'Test Workspace' }, + workspaceId: { value: 'test-workspace-id' }, + workspaceType: { value: 'personal' }, + workspaceRole: { value: 'owner' }, + isPersonalWorkspace: { value: true }, + isWorkspaceSubscribed: { value: true }, + subscriptionPlan: { value: null }, + permissions: { value: { canManageSubscription: true } }, + availableWorkspaces: { value: [] }, + fetchWorkspaces: vi.fn(), + switchWorkspace: vi.fn(), + subscribeWorkspace: vi.fn() + })) +})) + // Create i18n instance for testing const i18n = createI18n({ legacy: false, diff --git a/src/platform/cloud/subscription/components/SubscriptionPanelContent.vue b/src/platform/cloud/subscription/components/SubscriptionPanelContent.vue index e95dc7647..4c60eb6fb 100644 --- a/src/platform/cloud/subscription/components/SubscriptionPanelContent.vue +++ b/src/platform/cloud/subscription/components/SubscriptionPanelContent.vue @@ -16,7 +16,7 @@ @@ -236,6 +236,7 @@