Feat/workspaces 5 auth gate check (#8350)

## Summary

- Fix auth related race conditions with a new WorkspaceAuthGate in
App.vue
- De dup initialization calls 
- Add state machine to track state of refreshRemoteConfig
- Fix websocket not using new workspace jwt
- Misc improvments

## Changes

- **What**: Mainly WorkspaceAuthGate.vue
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8350-Feat-workspaces-5-auth-gate-check-2f66d73d365081b1a49afcd418fab3e7)
by [Unito](https://www.unito.io)
This commit is contained in:
Simula_r
2026-01-27 20:28:44 -08:00
committed by GitHub
parent d890e7568a
commit 34fc28a39d
12 changed files with 428 additions and 51 deletions

View File

@@ -0,0 +1,206 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import WorkspaceAuthGate from './WorkspaceAuthGate.vue'
const mockIsInitialized = ref(false)
const mockCurrentUser = ref<object | null>(null)
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
isInitialized: mockIsInitialized,
currentUser: mockCurrentUser
})
}))
const mockRefreshRemoteConfig = vi.fn()
vi.mock('@/platform/remoteConfig/refreshRemoteConfig', () => ({
refreshRemoteConfig: (options: unknown) => mockRefreshRemoteConfig(options)
}))
const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: false }))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
}
}
})
}))
const mockWorkspaceStoreInitialize = vi.fn()
const mockWorkspaceStoreInitState = vi.hoisted(() => ({
value: 'uninitialized' as string
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
get initState() {
return mockWorkspaceStoreInitState.value
},
initialize: mockWorkspaceStoreInitialize
})
}))
const mockIsCloud = vi.hoisted(() => ({ value: true }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
}))
vi.mock('primevue/progressspinner', () => ({
default: { template: '<div class="progress-spinner" />' }
}))
describe('WorkspaceAuthGate', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true
mockIsInitialized.value = false
mockCurrentUser.value = null
mockTeamWorkspacesEnabled.value = false
mockWorkspaceStoreInitState.value = 'uninitialized'
mockRefreshRemoteConfig.mockResolvedValue(undefined)
mockWorkspaceStoreInitialize.mockResolvedValue(undefined)
})
const mountComponent = () =>
mount(WorkspaceAuthGate, {
slots: {
default: '<div data-testid="slot-content">App Content</div>'
}
})
describe('non-cloud builds', () => {
it('renders slot immediately when isCloud is false', async () => {
mockIsCloud.value = false
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
expect(wrapper.find('.progress-spinner').exists()).toBe(false)
expect(mockRefreshRemoteConfig).not.toHaveBeenCalled()
})
})
describe('cloud builds - unauthenticated user', () => {
it('shows spinner while waiting for Firebase auth', () => {
mockIsInitialized.value = false
const wrapper = mountComponent()
expect(wrapper.find('.progress-spinner').exists()).toBe(true)
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(false)
})
it('renders slot when Firebase initializes with no user', async () => {
mockIsInitialized.value = false
const wrapper = mountComponent()
expect(wrapper.find('.progress-spinner').exists()).toBe(true)
mockIsInitialized.value = true
mockCurrentUser.value = null
await flushPromises()
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
expect(mockRefreshRemoteConfig).not.toHaveBeenCalled()
})
})
describe('cloud builds - authenticated user', () => {
beforeEach(() => {
mockIsInitialized.value = true
mockCurrentUser.value = { uid: 'user-123' }
})
it('refreshes remote config with auth after Firebase init', async () => {
mountComponent()
await flushPromises()
expect(mockRefreshRemoteConfig).toHaveBeenCalledWith({ useAuth: true })
})
it('renders slot when teamWorkspacesEnabled is false', async () => {
mockTeamWorkspacesEnabled.value = false
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
expect(mockWorkspaceStoreInitialize).not.toHaveBeenCalled()
})
it('initializes workspace store when teamWorkspacesEnabled is true', async () => {
mockTeamWorkspacesEnabled.value = true
const wrapper = mountComponent()
await flushPromises()
expect(mockWorkspaceStoreInitialize).toHaveBeenCalled()
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
})
it('skips workspace init when store is already initialized', async () => {
mockTeamWorkspacesEnabled.value = true
mockWorkspaceStoreInitState.value = 'ready'
const wrapper = mountComponent()
await flushPromises()
expect(mockWorkspaceStoreInitialize).not.toHaveBeenCalled()
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
})
})
describe('error handling - graceful degradation', () => {
beforeEach(() => {
mockIsInitialized.value = true
mockCurrentUser.value = { uid: 'user-123' }
})
it('renders slot when remote config refresh fails', async () => {
mockRefreshRemoteConfig.mockRejectedValue(new Error('Network error'))
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
})
it('renders slot when remote config refresh times out', async () => {
vi.useFakeTimers()
// Never-resolving promise simulates a hanging request
mockRefreshRemoteConfig.mockReturnValue(new Promise(() => {}))
const wrapper = mountComponent()
await flushPromises()
// Still showing spinner before timeout
expect(wrapper.find('.progress-spinner').exists()).toBe(true)
// Advance past the 10 second timeout
await vi.advanceTimersByTimeAsync(10_001)
await flushPromises()
// Should render slot after timeout (graceful degradation)
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
vi.useRealTimers()
})
it('renders slot when workspace store initialization fails', async () => {
mockTeamWorkspacesEnabled.value = true
mockWorkspaceStoreInitialize.mockRejectedValue(
new Error('Workspace init failed')
)
const wrapper = mountComponent()
await flushPromises()
expect(wrapper.find('[data-testid="slot-content"]').exists()).toBe(true)
})
})
})

View File

@@ -0,0 +1,126 @@
<template>
<slot v-if="isReady" />
<div
v-else
class="fixed inset-0 z-[1100] flex items-center justify-center bg-[var(--p-mask-background)]"
>
<ProgressSpinner />
</div>
</template>
<script setup lang="ts">
/**
* WorkspaceAuthGate - Conditional auth checkpoint for workspace mode.
*
* This gate ensures proper initialization order for workspace-scoped auth:
* 1. Wait for Firebase auth to resolve
* 2. Check if teamWorkspacesEnabled feature flag is on
* 3. If YES: Initialize workspace token and store before rendering
* 4. If NO: Render immediately using existing Firebase auth
*
* This prevents race conditions where API calls use Firebase tokens
* instead of workspace tokens when the workspace feature is enabled.
*/
import { promiseTimeout, until } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ProgressSpinner from 'primevue/progressspinner'
import { ref } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const FIREBASE_INIT_TIMEOUT_MS = 16_000
const CONFIG_REFRESH_TIMEOUT_MS = 10_000
const isReady = ref(!isCloud)
async function initialize(): Promise<void> {
if (!isCloud) return
const authStore = useFirebaseAuthStore()
const { isInitialized, currentUser } = storeToRefs(authStore)
try {
// Step 1: Wait for Firebase auth to resolve
// This is shared with router guard - both wait for the same thing,
// but this gate blocks rendering while router guard blocks navigation
if (!isInitialized.value) {
await until(isInitialized).toBe(true, {
timeout: FIREBASE_INIT_TIMEOUT_MS
})
}
// Step 2: If not authenticated, nothing more to do
// Unauthenticated users don't have workspace context
if (!currentUser.value) {
isReady.value = true
return
}
// Step 3: Refresh feature flags with auth context
// This ensures teamWorkspacesEnabled reflects the authenticated user's state
// Timeout prevents hanging if server is slow/unresponsive
try {
await Promise.race([
refreshRemoteConfig({ useAuth: true }),
promiseTimeout(CONFIG_REFRESH_TIMEOUT_MS).then(() => {
throw new Error('Config refresh timeout')
})
])
} catch (error) {
console.warn(
'[WorkspaceAuthGate] Failed to refresh remote config:',
error
)
// Continue - feature flags will use defaults (teamWorkspacesEnabled=false)
// App will render with Firebase auth fallback
}
// Step 4: THE CHECKPOINT - Are we in workspace mode?
const { flags } = useFeatureFlags()
if (!flags.teamWorkspacesEnabled) {
// Not in workspace mode - use existing Firebase auth flow
// No additional initialization needed
isReady.value = true
return
}
// Step 5: WORKSPACE MODE - Full initialization
await initializeWorkspaceMode()
} catch (error) {
console.error('[WorkspaceAuthGate] Initialization failed:', error)
} finally {
// Always render (graceful degradation)
// If workspace init failed, API calls fall back to Firebase token
isReady.value = true
}
}
async function initializeWorkspaceMode(): Promise<void> {
// Initialize the full workspace store which handles:
// - Restoring workspace token from session (fast path for refresh)
// - Fetching workspace list
// - Switching to last used workspace if needed
// - Setting active workspace
try {
const workspaceStore = useTeamWorkspaceStore()
if (workspaceStore.initState === 'uninitialized') {
await workspaceStore.initialize()
}
} catch (error) {
// Log but don't block - workspace UI features may not work but app will render
// API calls will fall back to Firebase token
console.warn(
'[WorkspaceAuthGate] Failed to initialize workspace store:',
error
)
}
}
// Start initialization immediately during component setup
// (not in onMounted, so initialization starts before DOM is ready)
void initialize()
</script>