mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-09 00:29:22 +00:00
Compare commits
9 Commits
feat/home-
...
weird-stal
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baa95a4c4e | ||
|
|
da410b633a | ||
|
|
99a6f1128b | ||
|
|
8accc836ee | ||
|
|
b68ac2f9fe | ||
|
|
a9d06e543a | ||
|
|
cb19d54eb1 | ||
|
|
6850877438 | ||
|
|
60d917807e |
@@ -11,6 +11,7 @@ import {
|
||||
WORKSPACE_FEATURE_FLAG
|
||||
} from '@e2e/fixtures/data/cloudWorkspace'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import { mockWorkspaceTokenMint } from '@e2e/fixtures/utils/workspaceMocks'
|
||||
|
||||
interface RoleChangeRequest {
|
||||
url: string
|
||||
@@ -92,9 +93,7 @@ export class CloudWorkspaceMockHelper {
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await mockWorkspaceTokenMint(page, TEAM_WORKSPACE)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
|
||||
@@ -33,6 +33,27 @@ export function member(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub `POST /api/auth/token` with a valid workspace token for `ws`. Without
|
||||
* this the mint fails and auth cannot resolve the active workspace.
|
||||
*/
|
||||
export async function mockWorkspaceTokenMint(
|
||||
page: Page,
|
||||
ws: Pick<WorkspaceWithRole, 'id' | 'name' | 'type' | 'role'>
|
||||
) {
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
workspace: { id: ws.id, name: ws.name, type: ws.type },
|
||||
role: ws.role,
|
||||
permissions: []
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub the workspace resolution + members list so the cloud app boots into the
|
||||
* given workspace with the given roster (drives the original-owner gate).
|
||||
@@ -46,17 +67,7 @@ export async function mockWorkspace(
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
await route.fulfill(jsonRoute({ workspaces: [ws] }))
|
||||
})
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
workspace: { id: ws.id, name: ws.name, type: ws.type },
|
||||
role: ws.role,
|
||||
permissions: []
|
||||
})
|
||||
)
|
||||
)
|
||||
await mockWorkspaceTokenMint(page, ws)
|
||||
await page.route('**/api/workspace/members**', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
|
||||
@@ -11,6 +11,10 @@ import type {
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import {
|
||||
mockWorkspaceTokenMint,
|
||||
workspace
|
||||
} from '@e2e/fixtures/utils/workspaceMocks'
|
||||
|
||||
/**
|
||||
* Billing facade consumers — FE-933 (B3) regression.
|
||||
@@ -81,6 +85,7 @@ async function mockCloudBoot(
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await mockWorkspaceTokenMint(page, workspace('personal', 'owner'))
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
// Single personal workspace.
|
||||
|
||||
@@ -7,6 +7,10 @@ import type { BillingStatusResponse } from '@/platform/workspace/api/workspaceAp
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import {
|
||||
mockWorkspaceTokenMint,
|
||||
workspace
|
||||
} from '@e2e/fixtures/utils/workspaceMocks'
|
||||
|
||||
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
|
||||
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
|
||||
@@ -97,6 +101,7 @@ async function mockCloudBoot(page: Page) {
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await mockWorkspaceTokenMint(page, workspace('personal', 'owner'))
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
// Single personal workspace.
|
||||
|
||||
@@ -360,6 +360,35 @@ describe('useTeamWorkspaceStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('forgetRevokedActiveWorkspace', () => {
|
||||
it.for([
|
||||
{ type: 'team', workspace: mockTeamWorkspace, reloads: 1 },
|
||||
{ type: 'personal', workspace: mockPersonalWorkspace, reloads: 0 }
|
||||
])(
|
||||
'reloads $reloads time(s) when the active $type workspace is revoked',
|
||||
async ({ workspace, reloads }) => {
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
store.activeWorkspaceId = workspace.id
|
||||
|
||||
store.forgetRevokedActiveWorkspace(workspace.id)
|
||||
|
||||
expect(mockLocalStorage.removeItem).toHaveBeenCalledTimes(reloads)
|
||||
expect(mockReload).toHaveBeenCalledTimes(reloads)
|
||||
}
|
||||
)
|
||||
|
||||
it('is a no-op when the workspace is not the active one', async () => {
|
||||
const store = useTeamWorkspaceStore()
|
||||
await store.initialize()
|
||||
store.activeWorkspaceId = mockTeamWorkspace.id
|
||||
|
||||
store.forgetRevokedActiveWorkspace('some-other-workspace')
|
||||
|
||||
expect(mockReload).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createWorkspace', () => {
|
||||
it('creates workspace and triggers reload', async () => {
|
||||
const newWorkspace = {
|
||||
|
||||
@@ -103,6 +103,14 @@ function setLastWorkspaceId(workspaceId: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function clearLastWorkspaceId(): void {
|
||||
try {
|
||||
localStorage.removeItem(WORKSPACE_STORAGE_KEYS.LAST_WORKSPACE_ID)
|
||||
} catch {
|
||||
console.warn('Failed to clear last workspace ID from localStorage')
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_OWNED_WORKSPACES = 10
|
||||
export const MAX_WORKSPACE_MEMBERS = 30
|
||||
const MAX_INIT_RETRIES = 3
|
||||
@@ -362,6 +370,20 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a revoked/deleted active workspace and reload so init falls back to the
|
||||
* personal workspace. Skips the personal workspace to avoid a reload loop.
|
||||
*/
|
||||
function forgetRevokedActiveWorkspace(workspaceId: string): void {
|
||||
if (activeWorkspaceId.value !== workspaceId) return
|
||||
|
||||
const revoked = workspaces.value.find((w) => w.id === workspaceId)
|
||||
if (revoked?.type === 'personal') return
|
||||
|
||||
clearLastWorkspaceId()
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different workspace.
|
||||
* Clears workspace context and reloads the page.
|
||||
@@ -744,6 +766,7 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
||||
|
||||
// Workspace Actions
|
||||
switchWorkspace,
|
||||
forgetRevokedActiveWorkspace,
|
||||
createWorkspace,
|
||||
deleteWorkspace,
|
||||
renameWorkspace,
|
||||
|
||||
@@ -11,11 +11,24 @@ import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
|
||||
const mockGetIdToken = vi.fn()
|
||||
const mockNotifyTokenRefreshed = vi.fn()
|
||||
const mockToastAdd = vi.fn()
|
||||
const mockCurrentUser = vi.hoisted((): { value: { uid: string } | null } => ({
|
||||
value: null
|
||||
}))
|
||||
const mockForgetRevokedActiveWorkspace = vi.fn()
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
getIdToken: mockGetIdToken,
|
||||
notifyTokenRefreshed: mockNotifyTokenRefreshed
|
||||
notifyTokenRefreshed: mockNotifyTokenRefreshed,
|
||||
get currentUser() {
|
||||
return mockCurrentUser.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
forgetRevokedActiveWorkspace: mockForgetRevokedActiveWorkspace
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -82,6 +95,7 @@ describe('useWorkspaceAuthStore', () => {
|
||||
sessionStorage.clear()
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockUnifiedCloudAuthEnabled.value = false
|
||||
mockCurrentUser.value = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -583,6 +597,301 @@ describe('useWorkspaceAuthStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureWorkspaceAuthHeader', () => {
|
||||
it('returns the existing header without minting when the token is valid', 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).toHaveBeenCalledTimes(1)
|
||||
|
||||
const header = await store.ensureWorkspaceAuthHeader('workspace-123')
|
||||
|
||||
expect(header).toEqual({ Authorization: 'Bearer workspace-token-abc' })
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('mints a token for the preferred workspace when none exists', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTokenResponse)
|
||||
})
|
||||
)
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
|
||||
const header = await store.ensureWorkspaceAuthHeader('workspace-123')
|
||||
|
||||
expect(header).toEqual({ Authorization: 'Bearer workspace-token-abc' })
|
||||
})
|
||||
|
||||
it('coalesces concurrent recovery onto a single mint', 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 [first, second] = await Promise.all([
|
||||
store.ensureWorkspaceAuthHeader('workspace-123'),
|
||||
store.ensureWorkspaceAuthHeader('workspace-123')
|
||||
])
|
||||
|
||||
expect(first).toEqual({ Authorization: 'Bearer workspace-token-abc' })
|
||||
expect(second).toEqual({ Authorization: 'Bearer workspace-token-abc' })
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('awaits an in-flight switch instead of racing a second mint', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
let resolveResponse: (value: unknown) => void = () => {}
|
||||
const responsePromise = new Promise((resolve) => {
|
||||
resolveResponse = resolve
|
||||
})
|
||||
const mockFetch = vi.fn().mockReturnValue(responsePromise)
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
|
||||
const switchPromise = store.switchWorkspace('workspace-123')
|
||||
const ensurePromise = store.ensureWorkspaceAuthHeader('workspace-123')
|
||||
|
||||
resolveResponse({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTokenResponse)
|
||||
})
|
||||
await switchPromise
|
||||
const header = await ensurePromise
|
||||
|
||||
expect(header).toEqual({ Authorization: 'Bearer workspace-token-abc' })
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns null (never a downgrade) when recovery fails transiently', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: () => Promise.resolve({})
|
||||
})
|
||||
)
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
|
||||
const header = await store.ensureWorkspaceAuthHeader('workspace-123')
|
||||
|
||||
expect(header).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when there is no workspace to recover to', async () => {
|
||||
const store = useWorkspaceAuthStore()
|
||||
|
||||
const header = await store.ensureWorkspaceAuthHeader()
|
||||
|
||||
expect(header).toBeNull()
|
||||
})
|
||||
|
||||
it('is a no-op returning null when the feature flag is disabled', async () => {
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
|
||||
const header = await store.ensureWorkspaceAuthHeader('workspace-123')
|
||||
|
||||
expect(header).toBeNull()
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tears down workspace context and surfaces a toast on a permanent recovery failure', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTokenResponse)
|
||||
})
|
||||
.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
json: () => Promise.resolve({ message: 'Access denied' })
|
||||
})
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
const { currentWorkspace } = storeToRefs(store)
|
||||
await store.switchWorkspace('workspace-123')
|
||||
|
||||
const token = await store.ensureWorkspaceToken('workspace-999')
|
||||
|
||||
expect(token).toBeNull()
|
||||
expect(currentWorkspace.value).toBeNull()
|
||||
expect(mockToastAdd).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('backs off re-minting after a failed recovery instead of retrying every call', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: () => Promise.resolve({})
|
||||
})
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
|
||||
const first = await store.ensureWorkspaceToken('workspace-123')
|
||||
const second = await store.ensureWorkspaceToken('workspace-123')
|
||||
|
||||
expect(first).toBeNull()
|
||||
expect(second).toBeNull()
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('forgets the revoked active workspace on a permanent workspace-selection failure', 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 token = await store.ensureWorkspaceToken('workspace-123')
|
||||
|
||||
expect(token).toBeNull()
|
||||
expect(mockForgetRevokedActiveWorkspace).toHaveBeenCalledWith(
|
||||
'workspace-123'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not forget the workspace when the failure is an auth error, not revocation', 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 token = await store.ensureWorkspaceToken('workspace-123')
|
||||
|
||||
expect(token).toBeNull()
|
||||
expect(mockForgetRevokedActiveWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves a valid context on a transient Firebase network failure while signed in', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTokenResponse)
|
||||
})
|
||||
)
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
const { currentWorkspace } = storeToRefs(store)
|
||||
await store.switchWorkspace('workspace-123')
|
||||
|
||||
mockCurrentUser.value = { uid: 'user-1' }
|
||||
mockGetIdToken.mockResolvedValue(undefined)
|
||||
|
||||
const token = await store.ensureWorkspaceToken('workspace-999')
|
||||
|
||||
expect(token).toBeNull()
|
||||
expect(currentWorkspace.value?.id).toBe('workspace-123')
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
expect(mockForgetRevokedActiveWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('collapses a burst of waiters into a single mint after a shared in-flight switch rejects', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: () => Promise.resolve({})
|
||||
})
|
||||
.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockTokenResponse)
|
||||
})
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
|
||||
const initialSwitch = store
|
||||
.switchWorkspace('workspace-123')
|
||||
.catch(() => {})
|
||||
const [first, second] = await Promise.all([
|
||||
store.ensureWorkspaceToken('workspace-123'),
|
||||
store.ensureWorkspaceToken('workspace-123')
|
||||
])
|
||||
await initialSwitch
|
||||
|
||||
expect(first).toBe('workspace-token-abc')
|
||||
expect(second).toBe('workspace-token-abc')
|
||||
// One failed initial switch + exactly one recovery mint the burst shares.
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('re-mints for the requested workspace rather than returning an in-flight switch to a different one', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
const mockFetch = vi.fn().mockImplementation((_url, options) => {
|
||||
const { workspace_id: workspaceId } = JSON.parse(options.body)
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
...mockTokenResponse,
|
||||
token: `token-${workspaceId}`,
|
||||
workspace: { ...mockWorkspace, id: workspaceId }
|
||||
})
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const store = useWorkspaceAuthStore()
|
||||
|
||||
const switchPromise = store.switchWorkspace('workspace-other')
|
||||
const token = await store.ensureWorkspaceToken('workspace-123')
|
||||
await switchPromise
|
||||
|
||||
expect(token).toBe('token-workspace-123')
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('token refresh scheduling', () => {
|
||||
it('schedules token refresh 5 minutes before expiry', async () => {
|
||||
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
TOKEN_REFRESH_BUFFER_MS,
|
||||
WORKSPACE_STORAGE_KEYS
|
||||
} from '@/platform/workspace/workspaceConstants'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
@@ -40,6 +41,8 @@ export type WorkspaceTokenResponse = z.infer<
|
||||
|
||||
const MAX_SCHEDULED_REFRESH_RETRIES = 3
|
||||
|
||||
const RECOVERY_COOLDOWN_MS = 5000
|
||||
|
||||
export class WorkspaceAuthError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -112,6 +115,8 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
// Timer state
|
||||
let refreshTimerId: ReturnType<typeof setTimeout> | null = null
|
||||
let inFlightSwitchCount = 0
|
||||
let inFlightSwitchPromise: Promise<void> | null = null
|
||||
let recoveryCooldownUntil = 0
|
||||
let scheduledRefreshRetryCount = 0
|
||||
// The unified lifecycle keeps its own timer + request-id so it never shares
|
||||
// mutable state with the legacy switchWorkspace/refreshToken machinery.
|
||||
@@ -371,7 +376,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function switchWorkspace(workspaceId: string): Promise<void> {
|
||||
async function performSwitchWorkspace(workspaceId: string): Promise<void> {
|
||||
if (!flags.teamWorkspacesEnabled) {
|
||||
return
|
||||
}
|
||||
@@ -399,6 +404,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
workspaceToken.value = token
|
||||
workspaceTokenExpiresAt.value = expiresAt
|
||||
scheduledRefreshRetryCount = 0
|
||||
recoveryCooldownUntil = 0
|
||||
|
||||
persistToSession(workspace, token, expiresAt)
|
||||
scheduleTokenRefresh(expiresAt)
|
||||
@@ -419,6 +425,124 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function switchWorkspace(workspaceId: string): Promise<void> {
|
||||
const promise = performSwitchWorkspace(workspaceId)
|
||||
inFlightSwitchPromise = promise
|
||||
void promise
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (inFlightSwitchPromise === promise) {
|
||||
inFlightSwitchPromise = null
|
||||
}
|
||||
})
|
||||
return promise
|
||||
}
|
||||
|
||||
function hasValidTokenForWorkspace(workspaceId: string | undefined): boolean {
|
||||
return (
|
||||
hasValidWorkspaceToken() &&
|
||||
(workspaceId === undefined || currentWorkspace.value?.id === workspaceId)
|
||||
)
|
||||
}
|
||||
|
||||
// NOT_AUTHENTICATED while still signed in is a transient network failure, not
|
||||
// a revoked session, so it must not tear down a valid context.
|
||||
function isPermanentRecoveryFailure(err: unknown): err is WorkspaceAuthError {
|
||||
if (!isPermanentAuthError(err)) {
|
||||
return false
|
||||
}
|
||||
if (err.code === 'NOT_AUTHENTICATED' && useAuthStore().currentUser) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// The workspace selection itself is invalid (revoked or deleted), so
|
||||
// abandoning it can help — unlike a plain auth failure.
|
||||
function isWorkspaceSelectionInvalid(
|
||||
err: unknown
|
||||
): err is WorkspaceAuthError {
|
||||
return (
|
||||
err instanceof WorkspaceAuthError &&
|
||||
(err.code === 'ACCESS_DENIED' || err.code === 'WORKSPACE_NOT_FOUND')
|
||||
)
|
||||
}
|
||||
|
||||
function startRecoveryCooldown(): void {
|
||||
recoveryCooldownUntil = Date.now() + RECOVERY_COOLDOWN_MS
|
||||
}
|
||||
|
||||
function handleRecoveryFailure(
|
||||
err: unknown,
|
||||
failedWorkspaceId?: string
|
||||
): void {
|
||||
if (isPermanentRecoveryFailure(err)) {
|
||||
const hadContext = currentWorkspace.value !== null
|
||||
clearWorkspaceContext()
|
||||
if (hadContext) {
|
||||
surfacePermanentAuthError(err)
|
||||
}
|
||||
if (failedWorkspaceId && isWorkspaceSelectionInvalid(err)) {
|
||||
useTeamWorkspaceStore().forgetRevokedActiveWorkspace(failedWorkspaceId)
|
||||
}
|
||||
}
|
||||
startRecoveryCooldown()
|
||||
console.warn('Workspace auth recovery failed:', err)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a valid workspace token, minting one if needed. Coalesces a burst of
|
||||
* callers onto a single in-flight mint, backs off after failure, and returns
|
||||
* null so callers fail closed rather than downgrade to the personal identity.
|
||||
*/
|
||||
async function ensureWorkspaceToken(
|
||||
preferredWorkspaceId?: string
|
||||
): Promise<string | null> {
|
||||
if (!flags.teamWorkspacesEnabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
const targetWorkspaceId = preferredWorkspaceId ?? currentWorkspace.value?.id
|
||||
|
||||
while (true) {
|
||||
if (hasValidTokenForWorkspace(targetWorkspaceId)) {
|
||||
return workspaceToken.value
|
||||
}
|
||||
|
||||
// Join any in-flight mint and re-check rather than launching our own.
|
||||
if (inFlightSwitchPromise) {
|
||||
await inFlightSwitchPromise.catch(() => {})
|
||||
continue
|
||||
}
|
||||
|
||||
if (!targetWorkspaceId || Date.now() < recoveryCooldownUntil) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
await switchWorkspace(targetWorkspaceId)
|
||||
} catch (err) {
|
||||
handleRecoveryFailure(err, targetWorkspaceId)
|
||||
return null
|
||||
}
|
||||
|
||||
if (hasValidTokenForWorkspace(targetWorkspaceId)) {
|
||||
return workspaceToken.value
|
||||
}
|
||||
|
||||
// Resolved without a usable token; back off like a failure and fail closed.
|
||||
startRecoveryCooldown()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureWorkspaceAuthHeader(
|
||||
preferredWorkspaceId?: string
|
||||
): Promise<AuthHeader | null> {
|
||||
const token = await ensureWorkspaceToken(preferredWorkspaceId)
|
||||
return token ? { Authorization: `Bearer ${token}` } : null
|
||||
}
|
||||
|
||||
async function refreshToken(): Promise<void> {
|
||||
if (!currentWorkspace.value) {
|
||||
return
|
||||
@@ -457,6 +581,9 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
if (!isStaleWorkspaceRequest(capturedRequestId)) {
|
||||
console.error('Workspace access revoked or auth invalid:', err)
|
||||
clearWorkspaceContext()
|
||||
if (isWorkspaceSelectionInvalid(err)) {
|
||||
useTeamWorkspaceStore().forgetRevokedActiveWorkspace(workspaceId)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -500,6 +627,9 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
//
|
||||
// A parallel mint/refresh lifecycle that writes to the dormant `unifiedToken`
|
||||
// slot. The legacy switchWorkspace/refreshToken machinery above is untouched.
|
||||
//
|
||||
// TODO(unified): call forgetRevokedActiveWorkspace on
|
||||
// ACCESS_DENIED/WORKSPACE_NOT_FOUND here too, with the PR-3 consumer flip.
|
||||
|
||||
// Mint body the unified session re-mints against: `{}` = personal (resolved
|
||||
// server-side from the Firebase identity), `{ workspace_id }` = explicit.
|
||||
@@ -663,6 +793,8 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
workspaceToken.value = null
|
||||
workspaceTokenExpiresAt.value = null
|
||||
scheduledRefreshRetryCount = 0
|
||||
recoveryCooldownUntil = 0
|
||||
inFlightSwitchPromise = null
|
||||
error.value = null
|
||||
clearSessionStorage()
|
||||
clearUnifiedContext()
|
||||
@@ -688,6 +820,8 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
|
||||
mintAtLogin,
|
||||
remintUnifiedOnce,
|
||||
getWorkspaceAuthHeader,
|
||||
ensureWorkspaceAuthHeader,
|
||||
ensureWorkspaceToken,
|
||||
getWorkspaceToken,
|
||||
clearWorkspaceContext
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
|
||||
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
|
||||
@@ -621,6 +623,114 @@ describe('useAuthStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAuthHeader workspace recovery', () => {
|
||||
beforeEach(() => {
|
||||
mockFeatureFlags.teamWorkspacesEnabled = true
|
||||
})
|
||||
|
||||
it('uses the workspace header when a valid workspace token exists', async () => {
|
||||
const workspaceAuth = useWorkspaceAuthStore()
|
||||
vi.spyOn(workspaceAuth, 'getWorkspaceAuthHeader').mockReturnValue({
|
||||
Authorization: 'Bearer ws-token'
|
||||
})
|
||||
|
||||
const header = await store.getAuthHeader()
|
||||
|
||||
expect(header).toEqual({ Authorization: 'Bearer ws-token' })
|
||||
expect(mockUser.getIdToken).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('recovers the workspace token instead of downgrading to personal auth', async () => {
|
||||
const workspaceAuth = useWorkspaceAuthStore()
|
||||
const teamStore = useTeamWorkspaceStore()
|
||||
teamStore.activeWorkspaceId = 'workspace-123'
|
||||
vi.spyOn(workspaceAuth, 'getWorkspaceAuthHeader').mockReturnValue(null)
|
||||
const ensureSpy = vi
|
||||
.spyOn(workspaceAuth, 'ensureWorkspaceAuthHeader')
|
||||
.mockResolvedValue({ Authorization: 'Bearer recovered-ws-token' })
|
||||
|
||||
const header = await store.getAuthHeader()
|
||||
|
||||
expect(ensureSpy).toHaveBeenCalledWith('workspace-123')
|
||||
expect(header).toEqual({ Authorization: 'Bearer recovered-ws-token' })
|
||||
expect(mockUser.getIdToken).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed (no personal Firebase downgrade) when recovery yields no token', async () => {
|
||||
const workspaceAuth = useWorkspaceAuthStore()
|
||||
const teamStore = useTeamWorkspaceStore()
|
||||
teamStore.activeWorkspaceId = 'workspace-123'
|
||||
vi.spyOn(workspaceAuth, 'getWorkspaceAuthHeader').mockReturnValue(null)
|
||||
vi.spyOn(workspaceAuth, 'ensureWorkspaceAuthHeader').mockResolvedValue(
|
||||
null
|
||||
)
|
||||
|
||||
const header = await store.getAuthHeader()
|
||||
|
||||
expect(header).toBeNull()
|
||||
expect(mockUser.getIdToken).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to Firebase when workspace mode is not yet initialized', async () => {
|
||||
const workspaceAuth = useWorkspaceAuthStore()
|
||||
const teamStore = useTeamWorkspaceStore()
|
||||
teamStore.activeWorkspaceId = null
|
||||
vi.spyOn(workspaceAuth, 'getWorkspaceAuthHeader').mockReturnValue(null)
|
||||
const ensureSpy = vi.spyOn(workspaceAuth, 'ensureWorkspaceAuthHeader')
|
||||
|
||||
const header = await store.getAuthHeader()
|
||||
|
||||
expect(ensureSpy).not.toHaveBeenCalled()
|
||||
expect(header).toEqual({ Authorization: 'Bearer mock-id-token' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAuthToken workspace recovery', () => {
|
||||
beforeEach(() => {
|
||||
mockFeatureFlags.teamWorkspacesEnabled = true
|
||||
})
|
||||
|
||||
it('recovers the workspace token instead of downgrading to personal auth', async () => {
|
||||
const workspaceAuth = useWorkspaceAuthStore()
|
||||
const teamStore = useTeamWorkspaceStore()
|
||||
teamStore.activeWorkspaceId = 'workspace-123'
|
||||
const ensureSpy = vi
|
||||
.spyOn(workspaceAuth, 'ensureWorkspaceToken')
|
||||
.mockResolvedValue('recovered-ws-token')
|
||||
|
||||
const token = await store.getAuthToken()
|
||||
|
||||
expect(ensureSpy).toHaveBeenCalledWith('workspace-123')
|
||||
expect(token).toBe('recovered-ws-token')
|
||||
expect(mockUser.getIdToken).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed (no personal Firebase downgrade) when recovery yields no token', async () => {
|
||||
const workspaceAuth = useWorkspaceAuthStore()
|
||||
const teamStore = useTeamWorkspaceStore()
|
||||
teamStore.activeWorkspaceId = 'workspace-123'
|
||||
vi.spyOn(workspaceAuth, 'ensureWorkspaceToken').mockResolvedValue(null)
|
||||
|
||||
const token = await store.getAuthToken()
|
||||
|
||||
expect(token).toBeUndefined()
|
||||
expect(mockUser.getIdToken).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to Firebase when workspace mode is not yet initialized', async () => {
|
||||
const workspaceAuth = useWorkspaceAuthStore()
|
||||
const teamStore = useTeamWorkspaceStore()
|
||||
teamStore.activeWorkspaceId = null
|
||||
vi.spyOn(workspaceAuth, 'getWorkspaceToken').mockReturnValue(undefined)
|
||||
const ensureSpy = vi.spyOn(workspaceAuth, 'ensureWorkspaceToken')
|
||||
|
||||
const token = await store.getAuthToken()
|
||||
|
||||
expect(ensureSpy).not.toHaveBeenCalled()
|
||||
expect(token).toBe('mock-id-token')
|
||||
})
|
||||
})
|
||||
|
||||
describe('social authentication', () => {
|
||||
describe('loginWithGoogle', () => {
|
||||
it('should sign in with Google', async () => {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import type { AuthHeader } from '@/types/authTypes'
|
||||
@@ -226,7 +227,16 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
|
||||
if (flags.teamWorkspacesEnabled) {
|
||||
const wsHeader = useWorkspaceAuthStore().getWorkspaceAuthHeader()
|
||||
const workspaceAuth = useWorkspaceAuthStore()
|
||||
const activeWorkspaceId = useTeamWorkspaceStore().activeWorkspaceId
|
||||
|
||||
// Recover the workspace token rather than downgrade to the personal
|
||||
// identity, which is what makes cloud requests oscillate.
|
||||
if (activeWorkspaceId) {
|
||||
return workspaceAuth.ensureWorkspaceAuthHeader(activeWorkspaceId)
|
||||
}
|
||||
|
||||
const wsHeader = workspaceAuth.getWorkspaceAuthHeader()
|
||||
if (wsHeader) return wsHeader
|
||||
}
|
||||
|
||||
@@ -261,7 +271,18 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
|
||||
if (flags.teamWorkspacesEnabled) {
|
||||
const wsToken = useWorkspaceAuthStore().getWorkspaceToken()
|
||||
const workspaceAuth = useWorkspaceAuthStore()
|
||||
const activeWorkspaceId = useTeamWorkspaceStore().activeWorkspaceId
|
||||
|
||||
// Mirror getAuthHeader for WebSocket/queue auth.
|
||||
if (activeWorkspaceId) {
|
||||
return (
|
||||
(await workspaceAuth.ensureWorkspaceToken(activeWorkspaceId)) ??
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
const wsToken = workspaceAuth.getWorkspaceToken()
|
||||
if (wsToken) return wsToken
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user