Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1a92e9d
feat: add per-tab workspace authentication infrastructure
christian-byrne Jan 15, 2026
6f36d3a
feat: gate workspace auth behind team_workspaces_enabled feature flag
christian-byrne Jan 15, 2026
2549440
refactor: export WORKSPACE_STORAGE_KEYS constant for shared use
christian-byrne Jan 15, 2026
9f44d20
fix: add missing afterEach import in useWorkspaceAuth.test.ts
christian-byrne Jan 15, 2026
53fbe86
refactor: consolidate initial state tests into single test
christian-byrne Jan 15, 2026
5c15818
fix: add Zod validation for API response and validate expires_at
christian-byrne Jan 15, 2026
6240acc
fix: validate workspace data in initializeFromSession
christian-byrne Jan 15, 2026
e0a3bc3
feat: add i18n keys for workspace auth and unsaved changes dialog
christian-byrne Jan 15, 2026
3f24155
fix: use vi.hoisted() for mock variables in useWorkspaceSwitch.test.ts
christian-byrne Jan 15, 2026
b9b9d96
test: add edge case test for isAuthenticated when token is null
christian-byrne Jan 15, 2026
afe6129
fix: add JSDoc and runtime guard for component setup context
christian-byrne Jan 15, 2026
175647a
fix: add exponential backoff retry for refreshToken transient failures
christian-byrne Jan 15, 2026
aeb77cc
chore: remove unused WorkspaceTokenResponse interface
christian-byrne Jan 15, 2026
9e7d8e3
refactor: use VueUse useTimeoutFn, improve refreshToken error handlin…
christian-byrne Jan 15, 2026
9a04df5
refactor(auth): convert useWorkspaceAuth to Pinia store and fix auth …
christian-byrne Jan 15, 2026
f22e4f0
refactor: remove useWorkspaceAuth wrapper, use store directly
christian-byrne Jan 15, 2026
401557c
fix(auth): clear workspace on logout and prevent stale refresh races
simula-r Jan 15, 2026
29d84fc
chore: remove temporary documentation files
simula-r Jan 16, 2026
24e7d95
fix(auth): check all workflows for unsaved changes on workspace switch
simula-r Jan 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/composables/auth/useCurrentUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ export const useCurrentUser = () => {
whenever(() => authStore.tokenRefreshTrigger, callback)

const onUserLogout = (callback: () => void) => {
watch(resolvedUserInfo, (user) => {
if (!user) callback()
watch(resolvedUserInfo, (user, prevUser) => {
if (prevUser && !user) callback()
})
Comment on lines 43 to 46
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Return the watcher stop handle to allow cleanup.

watch() returns a disposer; exposing it avoids lingering subscriptions when onUserLogout is used outside a component scope or registered multiple times. Based on learnings, consider returning the stop handle for cleanup.

♻️ Proposed fix
-  const onUserLogout = (callback: () => void) => {
-    watch(resolvedUserInfo, (user, prevUser) => {
-      if (prevUser && !user) callback()
-    })
-  }
+  const onUserLogout = (callback: () => void) =>
+    watch(resolvedUserInfo, (user, prevUser) => {
+      if (prevUser && !user) callback()
+    })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const onUserLogout = (callback: () => void) => {
watch(resolvedUserInfo, (user) => {
if (!user) callback()
watch(resolvedUserInfo, (user, prevUser) => {
if (prevUser && !user) callback()
})
const onUserLogout = (callback: () => void) =>
watch(resolvedUserInfo, (user, prevUser) => {
if (prevUser && !user) callback()
})
🤖 Prompt for AI Agents
In `@src/composables/auth/useCurrentUser.ts` around lines 43 - 46, The
onUserLogout helper doesn't return the watcher disposer, so callers can't stop
the watch and may leak subscriptions; update onUserLogout(resolvedUserInfo
usage) to return the stop handle produced by watch(...) (i.e., the disposer
function) so consumers can call it to cleanup when needed—ensure the function
returns that value instead of void.

}

Expand Down
9 changes: 8 additions & 1 deletion src/composables/useFeatureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export enum ServerFeatureFlag {
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled',
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
ASYNC_MODEL_UPLOAD_ENABLED = 'async_model_upload_enabled'
ASYNC_MODEL_UPLOAD_ENABLED = 'async_model_upload_enabled',
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled'
}

/**
Expand Down Expand Up @@ -92,6 +93,12 @@ export function useFeatureFlags() {
false
)
)
},
get teamWorkspacesEnabled() {
return (
remoteConfig.value.team_workspaces_enabled ??
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
)
}
})

Expand Down
15 changes: 15 additions & 0 deletions src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -2592,5 +2592,20 @@
"completed": "Completed",
"failed": "Failed"
}
},
"workspace": {
"unsavedChanges": {
"title": "Unsaved Changes",
"message": "You have unsaved changes. Do you want to discard them and switch workspaces?"
}
},
"workspaceAuth": {
"errors": {
"notAuthenticated": "You must be logged in to access workspaces",
"invalidFirebaseToken": "Authentication failed. Please try logging in again.",
"accessDenied": "You do not have access to this workspace",
"workspaceNotFound": "Workspace not found",
"tokenExchangeFailed": "Failed to authenticate with workspace: {error}"
}
}
}
88 changes: 61 additions & 27 deletions src/platform/auth/session/useSessionCookie.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { api } from '@/scripts/api'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'

/**
Expand All @@ -10,31 +11,59 @@ export const useSessionCookie = () => {
/**
* Creates or refreshes the session cookie.
* Called after login and on token refresh.
*
* When team_workspaces_enabled is true, uses Firebase token directly
* (since getAuthHeader() returns workspace token which shouldn't be used for session creation).
* When disabled, uses getAuthHeader() for backward compatibility.
*/
const createSession = async (): Promise<void> => {
if (!isCloud) return

const authStore = useFirebaseAuthStore()
const authHeader = await authStore.getAuthHeader()
try {
const authStore = useFirebaseAuthStore()

if (!authHeader) {
throw new Error('No auth header available for session creation')
}
let authHeader: Record<string, string>

const response = await fetch(api.apiURL('/auth/session'), {
method: 'POST',
credentials: 'include',
headers: {
...authHeader,
'Content-Type': 'application/json'
if (remoteConfig.value.team_workspaces_enabled) {
const firebaseToken = await authStore.getIdToken()
if (!firebaseToken) {
console.warn(
'Failed to create session cookie:',
'No Firebase token available for session creation'
)
return
}
authHeader = { Authorization: `Bearer ${firebaseToken}` }
} else {
const header = await authStore.getAuthHeader()
if (!header) {
console.warn(
'Failed to create session cookie:',
'No auth header available for session creation'
)
return
}
authHeader = header
}
})

if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(
`Failed to create session: ${errorData.message || response.statusText}`
)
const response = await fetch(api.apiURL('/auth/session'), {
method: 'POST',
credentials: 'include',
headers: {
...authHeader,
'Content-Type': 'application/json'
}
})

if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
console.warn(
'Failed to create session cookie:',
errorData.message || response.statusText
)
}
} catch (error) {
console.warn('Failed to create session cookie:', error)
}
}

Expand All @@ -45,16 +74,21 @@ export const useSessionCookie = () => {
const deleteSession = async (): Promise<void> => {
if (!isCloud) return

const response = await fetch(api.apiURL('/auth/session'), {
method: 'DELETE',
credentials: 'include'
})
try {
const response = await fetch(api.apiURL('/auth/session'), {
method: 'DELETE',
credentials: 'include'
})

if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(
`Failed to delete session: ${errorData.message || response.statusText}`
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
console.warn(
'Failed to delete session cookie:',
errorData.message || response.statusText
)
}
} catch (error) {
console.warn('Failed to delete session cookie:', error)
}
}

Expand Down
Loading