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