feat: implement per-tab workspace authentication

- Add useWorkspaceAuth composable for workspace token management
- Add WorkspaceSwitcher component to topbar for switching workspaces
- Add WorkspaceSelector dropdown component
- Update firebaseAuthStore.getAuthHeader() to prioritize workspace tokens
- Add workspace types aligned with backend API (cloud PR #1995)

Key features:
- Per-tab workspace isolation via sessionStorage
- Auto-refresh tokens 5 minutes before expiry
- Unsaved changes confirmation before switching
- Firebase token exchange for workspace-scoped JWT

Co-authored-by: anthropic/claude <noreply@anthropic.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019bbf2e-fe27-72da-a997-cb99968d8e61
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
bymyself
2026-01-14 18:32:22 -08:00
parent 3069c24f81
commit a41e331dab
10 changed files with 1357 additions and 6 deletions

View File

@@ -59,6 +59,7 @@
{{ queuedCount }}
</span>
</Button>
<WorkspaceSwitcher v-if="isLoggedIn && !isIntegratedTabBar" />
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
@@ -99,6 +100,7 @@ import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import WorkspaceSwitcher from '@/platform/auth/workspace/components/WorkspaceSwitcher.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { app } from '@/scripts/app'

View File

@@ -1893,6 +1893,38 @@
"tooltipLearnMore": "Learn more..."
}
},
"workspace": {
"selector": {
"title": "Select Workspace",
"loading": "Loading workspaces...",
"noWorkspaces": "No workspaces available",
"personalWorkspace": "Personal Workspace",
"switchTo": "Switch to {name}",
"current": "Current"
},
"switcher": {
"label": "Workspace",
"tooltip": "Switch workspace"
},
"errors": {
"fetchFailed": "Failed to load workspaces",
"switchFailed": "Failed to switch workspace",
"tokenRefreshFailed": "Failed to refresh workspace access",
"notAuthenticated": "Please sign in to access workspaces",
"accessDenied": "You don't have access to this workspace",
"workspaceNotFound": "Workspace not found"
},
"roles": {
"owner": "Owner",
"member": "Member"
},
"unsavedChanges": {
"title": "Unsaved Changes",
"message": "You have unsaved changes. Switching workspaces will reload the page and any unsaved work will be lost.",
"confirm": "Switch Anyway",
"cancel": "Stay Here"
}
},
"validation": {
"invalidEmail": "Invalid email address",
"required": "Required",
@@ -2588,5 +2620,14 @@
"completed": "Completed",
"failed": "Failed"
}
},
"workspaceAuth": {
"errors": {
"notAuthenticated": "You must be signed in to access workspaces",
"invalidFirebaseToken": "Authentication expired. Please sign in again.",
"accessDenied": "You do not have access to this workspace",
"workspaceNotFound": "Workspace not found",
"tokenExchangeFailed": "Failed to access workspace: {error}"
}
}
}

View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { WorkspaceRole, WorkspaceWithRole } from '@/types/workspaceTypes'
import { cn } from '@/utils/tailwindUtil'
const { workspaces, currentWorkspaceId, isLoading } = defineProps<{
workspaces: WorkspaceWithRole[]
currentWorkspaceId: string | null
isLoading: boolean
}>()
const emit = defineEmits<{
select: [workspaceId: string]
}>()
const { t } = useI18n()
const sortedWorkspaces = computed(() => {
return [...workspaces].sort((a, b) => {
if (a.role === 'owner' && b.role !== 'owner') return -1
if (a.role !== 'owner' && b.role === 'owner') return 1
return a.name.localeCompare(b.name)
})
})
function getRoleBadgeClass(role: WorkspaceRole): string {
switch (role) {
case 'owner':
return 'bg-emerald-500/20 text-emerald-400'
case 'member':
return 'bg-blue-500/20 text-blue-400'
}
}
function handleWorkspaceClick(workspace: WorkspaceWithRole): void {
if (workspace.id !== currentWorkspaceId) {
emit('select', workspace.id)
}
}
</script>
<template>
<div class="flex flex-col w-64 max-h-80">
<div class="px-3 py-2 border-b border-border-subtle">
<span class="text-sm font-medium text-muted-foreground">
{{ t('workspace.selector.title') }}
</span>
</div>
<div v-if="isLoading" class="flex items-center justify-center py-8">
<i class="pi pi-spin pi-spinner text-muted-foreground" />
<span class="ml-2 text-sm text-muted-foreground">
{{ t('workspace.selector.loading') }}
</span>
</div>
<div
v-else-if="workspaces.length === 0"
class="flex items-center justify-center py-8"
>
<span class="text-sm text-muted-foreground">
{{ t('workspace.selector.noWorkspaces') }}
</span>
</div>
<div v-else class="flex flex-col py-1 overflow-y-auto">
<Button
v-for="workspace in sortedWorkspaces"
:key="workspace.id"
variant="textonly"
:class="
cn(
'flex items-center justify-between w-full px-3 py-2 text-left rounded-none',
workspace.id === currentWorkspaceId &&
'bg-secondary-background-hover'
)
"
@click="handleWorkspaceClick(workspace)"
>
<div class="flex items-center gap-2 min-w-0 flex-1">
<i
:class="
cn(
'icon-[lucide--building-2] text-base shrink-0',
workspace.id === currentWorkspaceId
? 'text-base-foreground'
: 'text-muted-foreground'
)
"
/>
<span
:class="
cn(
'text-sm truncate',
workspace.id === currentWorkspaceId
? 'text-base-foreground font-medium'
: 'text-muted-foreground'
)
"
>
{{ workspace.name }}
</span>
</div>
<div class="flex items-center gap-2 shrink-0">
<span
:class="
cn(
'text-xs px-1.5 py-0.5 rounded-full',
getRoleBadgeClass(workspace.role)
)
"
>
{{ t(`workspace.roles.${workspace.role}`) }}
</span>
<i
v-if="workspace.id === currentWorkspaceId"
class="icon-[lucide--check] text-emerald-400 text-sm"
/>
</div>
</Button>
</div>
</div>
</template>

View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import {
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import WorkspaceSelector from '@/platform/auth/workspace/components/WorkspaceSelector.vue'
import { useWorkspaceAuth } from '@/platform/auth/workspace/useWorkspaceAuth'
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type {
ListWorkspacesResponse,
WorkspaceWithRole
} from '@/types/workspaceTypes'
const { t } = useI18n()
const workspaceAuth = useWorkspaceAuth()
const firebaseAuthStore = useFirebaseAuthStore()
const { switchWithConfirmation } = useWorkspaceSwitch()
const isOpen = ref(false)
const workspaces = ref<WorkspaceWithRole[]>([])
const isLoadingWorkspaces = ref(false)
const fetchError = ref<string | null>(null)
watch(isOpen, (open) => {
if (open) {
void fetchWorkspaces()
}
})
async function fetchWorkspaces(): Promise<void> {
isLoadingWorkspaces.value = true
fetchError.value = null
try {
const firebaseToken = await firebaseAuthStore.getIdToken()
if (!firebaseToken) {
throw new Error(t('workspace.errors.notAuthenticated'))
}
const response = await fetch(`${getComfyApiBaseUrl()}/api/workspaces`, {
headers: {
Authorization: `Bearer ${firebaseToken}`
}
})
if (!response.ok) {
throw new Error(t('workspace.errors.fetchFailed'))
}
const data: ListWorkspacesResponse = await response.json()
workspaces.value = data.workspaces ?? []
} catch (err) {
fetchError.value =
err instanceof Error ? err.message : t('workspace.errors.fetchFailed')
workspaces.value = []
} finally {
isLoadingWorkspaces.value = false
}
}
async function handleWorkspaceSelect(workspaceId: string): Promise<void> {
isOpen.value = false
await switchWithConfirmation(workspaceId)
}
</script>
<template>
<PopoverRoot v-model:open="isOpen">
<PopoverTrigger as-child>
<Button
v-tooltip="{ value: t('workspace.switcher.tooltip'), showDelay: 300 }"
variant="textonly"
size="sm"
class="flex items-center gap-1.5 max-w-40"
data-testid="workspace-switcher-button"
>
<i class="icon-[lucide--building-2] text-base text-muted-foreground" />
<span class="truncate text-sm text-muted-foreground">
{{
workspaceAuth.currentWorkspace.value?.name ??
t('workspace.switcher.label')
}}
</span>
<i
class="icon-[lucide--chevron-down] shrink-0 text-sm text-muted-foreground"
/>
</Button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
side="bottom"
:side-offset="5"
:collision-padding="10"
class="rounded-lg bg-base-background shadow-sm border border-border-subtle will-change-[transform,opacity] data-[state=open]:data-[side=bottom]:animate-slideUpAndFade z-[100]"
>
<WorkspaceSelector
:workspaces="workspaces"
:current-workspace-id="
workspaceAuth.currentWorkspace.value?.id ?? null
"
:is-loading="isLoadingWorkspaces"
@select="handleWorkspaceSelect"
/>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>

View File

@@ -0,0 +1,584 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkspaceAuth, WorkspaceAuthError } from './useWorkspaceAuth'
const mockGetIdToken = vi.fn()
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
getIdToken: mockGetIdToken
})
}))
vi.mock('@/config/comfyApi', () => ({
getComfyApiBaseUrl: () => 'https://api.example.com'
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
const STORAGE_KEYS = {
CURRENT_WORKSPACE: 'Comfy.Workspace.Current',
TOKEN: 'Comfy.Workspace.Token',
EXPIRES_AT: 'Comfy.Workspace.ExpiresAt'
}
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('useWorkspaceAuth', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
vi.useFakeTimers()
sessionStorage.clear()
})
afterEach(() => {
vi.useRealTimers()
})
describe('initial state', () => {
it('currentWorkspace is null initially', () => {
const { currentWorkspace } = useWorkspaceAuth()
expect(currentWorkspace.value).toBeNull()
})
it('workspaceToken is null initially', () => {
const { workspaceToken } = useWorkspaceAuth()
expect(workspaceToken.value).toBeNull()
})
it('isAuthenticated is false initially', () => {
const { isAuthenticated } = useWorkspaceAuth()
expect(isAuthenticated.value).toBe(false)
})
it('isLoading is false initially', () => {
const { isLoading } = useWorkspaceAuth()
expect(isLoading.value).toBe(false)
})
it('error is null initially', () => {
const { error } = useWorkspaceAuth()
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(
STORAGE_KEYS.CURRENT_WORKSPACE,
JSON.stringify(mockWorkspaceWithRole)
)
sessionStorage.setItem(STORAGE_KEYS.TOKEN, 'valid-token')
sessionStorage.setItem(STORAGE_KEYS.EXPIRES_AT, futureExpiry.toString())
const { initializeFromSession, currentWorkspace, workspaceToken } =
useWorkspaceAuth()
const result = initializeFromSession()
expect(result).toBe(true)
expect(currentWorkspace.value).toEqual(mockWorkspaceWithRole)
expect(workspaceToken.value).toBe('valid-token')
})
it('returns false when sessionStorage is empty', () => {
const { initializeFromSession } = useWorkspaceAuth()
const result = initializeFromSession()
expect(result).toBe(false)
})
it('returns false and clears storage when token is expired', () => {
const pastExpiry = Date.now() - 1000
sessionStorage.setItem(
STORAGE_KEYS.CURRENT_WORKSPACE,
JSON.stringify(mockWorkspaceWithRole)
)
sessionStorage.setItem(STORAGE_KEYS.TOKEN, 'expired-token')
sessionStorage.setItem(STORAGE_KEYS.EXPIRES_AT, pastExpiry.toString())
const { initializeFromSession } = useWorkspaceAuth()
const result = initializeFromSession()
expect(result).toBe(false)
expect(sessionStorage.getItem(STORAGE_KEYS.CURRENT_WORKSPACE)).toBeNull()
expect(sessionStorage.getItem(STORAGE_KEYS.TOKEN)).toBeNull()
expect(sessionStorage.getItem(STORAGE_KEYS.EXPIRES_AT)).toBeNull()
})
it('returns false and clears storage when data is malformed', () => {
sessionStorage.setItem(STORAGE_KEYS.CURRENT_WORKSPACE, 'invalid-json{')
sessionStorage.setItem(STORAGE_KEYS.TOKEN, 'some-token')
sessionStorage.setItem(STORAGE_KEYS.EXPIRES_AT, 'not-a-number')
const { initializeFromSession } = useWorkspaceAuth()
const result = initializeFromSession()
expect(result).toBe(false)
expect(sessionStorage.getItem(STORAGE_KEYS.CURRENT_WORKSPACE)).toBeNull()
expect(sessionStorage.getItem(STORAGE_KEYS.TOKEN)).toBeNull()
expect(sessionStorage.getItem(STORAGE_KEYS.EXPIRES_AT)).toBeNull()
})
it('returns false when partial session data exists (missing token)', () => {
sessionStorage.setItem(
STORAGE_KEYS.CURRENT_WORKSPACE,
JSON.stringify(mockWorkspaceWithRole)
)
sessionStorage.setItem(
STORAGE_KEYS.EXPIRES_AT,
(Date.now() + 3600 * 1000).toString()
)
const { initializeFromSession } = useWorkspaceAuth()
const result = 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 {
switchWorkspace,
currentWorkspace,
workspaceToken,
isAuthenticated
} = useWorkspaceAuth()
await 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 { switchWorkspace } = useWorkspaceAuth()
await switchWorkspace('workspace-123')
expect(sessionStorage.getItem(STORAGE_KEYS.CURRENT_WORKSPACE)).toBe(
JSON.stringify(mockWorkspaceWithRole)
)
expect(sessionStorage.getItem(STORAGE_KEYS.TOKEN)).toBe(
'workspace-token-abc'
)
expect(sessionStorage.getItem(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 { switchWorkspace, isLoading } = useWorkspaceAuth()
const switchPromise = 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 { switchWorkspace, error } = useWorkspaceAuth()
await expect(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 { switchWorkspace, error } = useWorkspaceAuth()
await expect(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 { switchWorkspace, error } = useWorkspaceAuth()
await expect(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 { switchWorkspace, error } = useWorkspaceAuth()
await expect(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 { switchWorkspace, error } = useWorkspaceAuth()
await expect(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 { switchWorkspace } = useWorkspaceAuth()
await 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 {
switchWorkspace,
clearWorkspaceContext,
currentWorkspace,
workspaceToken,
error,
isAuthenticated
} = useWorkspaceAuth()
await switchWorkspace('workspace-123')
expect(isAuthenticated.value).toBe(true)
clearWorkspaceContext()
expect(currentWorkspace.value).toBeNull()
expect(workspaceToken.value).toBeNull()
expect(error.value).toBeNull()
expect(isAuthenticated.value).toBe(false)
})
it('clears sessionStorage', async () => {
sessionStorage.setItem(
STORAGE_KEYS.CURRENT_WORKSPACE,
JSON.stringify(mockWorkspaceWithRole)
)
sessionStorage.setItem(STORAGE_KEYS.TOKEN, 'some-token')
sessionStorage.setItem(STORAGE_KEYS.EXPIRES_AT, '12345')
const { clearWorkspaceContext } = useWorkspaceAuth()
clearWorkspaceContext()
expect(sessionStorage.getItem(STORAGE_KEYS.CURRENT_WORKSPACE)).toBeNull()
expect(sessionStorage.getItem(STORAGE_KEYS.TOKEN)).toBeNull()
expect(sessionStorage.getItem(STORAGE_KEYS.EXPIRES_AT)).toBeNull()
})
})
describe('getWorkspaceAuthHeader', () => {
it('returns null when no workspace token', () => {
const { getWorkspaceAuthHeader } = useWorkspaceAuth()
const header = 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 { switchWorkspace, getWorkspaceAuthHeader } = useWorkspaceAuth()
await switchWorkspace('workspace-123')
const header = 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 { switchWorkspace } = useWorkspaceAuth()
await switchWorkspace('workspace-123')
expect(mockFetch).toHaveBeenCalledTimes(1)
const refreshBufferMs = 5 * 60 * 1000
const refreshDelay = expiresInMs - refreshBufferMs
vi.advanceTimersByTime(refreshDelay - 1)
expect(mockFetch).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(1)
await Promise.resolve()
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 { switchWorkspace, currentWorkspace, workspaceToken } =
useWorkspaceAuth()
await 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 { refreshToken } = useWorkspaceAuth()
await 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 { switchWorkspace, refreshToken, workspaceToken } =
useWorkspaceAuth()
await switchWorkspace('workspace-123')
expect(mockFetch).toHaveBeenCalledTimes(1)
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
...mockTokenResponse,
token: 'refreshed-token'
})
})
await 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 { switchWorkspace, isAuthenticated } = useWorkspaceAuth()
await switchWorkspace('workspace-123')
expect(isAuthenticated.value).toBe(true)
})
it('returns false when workspace is null', () => {
const { isAuthenticated } = useWorkspaceAuth()
expect(isAuthenticated.value).toBe(false)
})
})
})

View File

@@ -0,0 +1,243 @@
import { computed, onUnmounted, ref, shallowRef } from 'vue'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { AuthHeader } from '@/types/authTypes'
import type {
WorkspaceTokenResponse,
WorkspaceWithRole
} from '@/types/workspaceTypes'
const STORAGE_KEYS = {
CURRENT_WORKSPACE: 'Comfy.Workspace.Current',
TOKEN: 'Comfy.Workspace.Token',
EXPIRES_AT: 'Comfy.Workspace.ExpiresAt'
} as const
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000
export class WorkspaceAuthError extends Error {
constructor(
message: string,
public readonly code?: string
) {
super(message)
this.name = 'WorkspaceAuthError'
}
}
export function useWorkspaceAuth() {
const firebaseAuthStore = useFirebaseAuthStore()
const currentWorkspace = shallowRef<WorkspaceWithRole | null>(null)
const workspaceToken = ref<string | null>(null)
const isLoading = ref(false)
const error = ref<Error | null>(null)
let refreshTimer: ReturnType<typeof setTimeout> | null = null
const isAuthenticated = computed(
() => currentWorkspace.value !== null && workspaceToken.value !== null
)
function scheduleTokenRefresh(expiresAt: number): void {
clearRefreshTimer()
const now = Date.now()
const refreshAt = expiresAt - TOKEN_REFRESH_BUFFER_MS
const delay = Math.max(0, refreshAt - now)
refreshTimer = setTimeout(() => {
void refreshToken()
}, delay)
}
function clearRefreshTimer(): void {
if (refreshTimer !== null) {
clearTimeout(refreshTimer)
refreshTimer = null
}
}
function persistToSession(
workspace: WorkspaceWithRole,
token: string,
expiresAt: number
): void {
try {
sessionStorage.setItem(
STORAGE_KEYS.CURRENT_WORKSPACE,
JSON.stringify(workspace)
)
sessionStorage.setItem(STORAGE_KEYS.TOKEN, token)
sessionStorage.setItem(STORAGE_KEYS.EXPIRES_AT, expiresAt.toString())
} catch {
console.warn('Failed to persist workspace context to sessionStorage')
}
}
function clearSessionStorage(): void {
try {
sessionStorage.removeItem(STORAGE_KEYS.CURRENT_WORKSPACE)
sessionStorage.removeItem(STORAGE_KEYS.TOKEN)
sessionStorage.removeItem(STORAGE_KEYS.EXPIRES_AT)
} catch {
console.warn('Failed to clear workspace context from sessionStorage')
}
}
function initializeFromSession(): boolean {
try {
const workspaceJson = sessionStorage.getItem(
STORAGE_KEYS.CURRENT_WORKSPACE
)
const token = sessionStorage.getItem(STORAGE_KEYS.TOKEN)
const expiresAtStr = sessionStorage.getItem(STORAGE_KEYS.EXPIRES_AT)
if (!workspaceJson || !token || !expiresAtStr) {
return false
}
const expiresAt = parseInt(expiresAtStr, 10)
if (isNaN(expiresAt) || expiresAt <= Date.now()) {
clearSessionStorage()
return false
}
const workspace = JSON.parse(workspaceJson) as WorkspaceWithRole
currentWorkspace.value = workspace
workspaceToken.value = token
error.value = null
scheduleTokenRefresh(expiresAt)
return true
} catch {
clearSessionStorage()
return false
}
}
async function switchWorkspace(workspaceId: string): Promise<void> {
isLoading.value = true
error.value = null
try {
const firebaseToken = await firebaseAuthStore.getIdToken()
if (!firebaseToken) {
throw new WorkspaceAuthError(
t('workspaceAuth.errors.notAuthenticated'),
'NOT_AUTHENTICATED'
)
}
const response = await fetch(`${getComfyApiBaseUrl()}/api/auth/token`, {
method: 'POST',
headers: {
Authorization: `Bearer ${firebaseToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ workspace_id: workspaceId })
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const message = errorData.message || response.statusText
if (response.status === 401) {
throw new WorkspaceAuthError(
t('workspaceAuth.errors.invalidFirebaseToken'),
'INVALID_FIREBASE_TOKEN'
)
}
if (response.status === 403) {
throw new WorkspaceAuthError(
t('workspaceAuth.errors.accessDenied'),
'ACCESS_DENIED'
)
}
if (response.status === 404) {
throw new WorkspaceAuthError(
t('workspaceAuth.errors.workspaceNotFound'),
'WORKSPACE_NOT_FOUND'
)
}
throw new WorkspaceAuthError(
t('workspaceAuth.errors.tokenExchangeFailed', { error: message }),
'TOKEN_EXCHANGE_FAILED'
)
}
const data: WorkspaceTokenResponse = await response.json()
const expiresAt = new Date(data.expires_at).getTime()
const workspaceWithRole: WorkspaceWithRole = {
...data.workspace,
role: data.role
}
currentWorkspace.value = workspaceWithRole
workspaceToken.value = data.token
persistToSession(workspaceWithRole, data.token, expiresAt)
scheduleTokenRefresh(expiresAt)
} catch (err) {
error.value = err instanceof Error ? err : new Error(String(err))
throw error.value
} finally {
isLoading.value = false
}
}
async function refreshToken(): Promise<void> {
if (!currentWorkspace.value) {
return
}
try {
await switchWorkspace(currentWorkspace.value.id)
} catch (err) {
console.error('Failed to refresh workspace token:', err)
if (
err instanceof WorkspaceAuthError &&
(err.code === 'ACCESS_DENIED' || err.code === 'WORKSPACE_NOT_FOUND')
) {
clearWorkspaceContext()
}
}
}
function getWorkspaceAuthHeader(): AuthHeader | null {
if (!workspaceToken.value) {
return null
}
return {
Authorization: `Bearer ${workspaceToken.value}`
}
}
function clearWorkspaceContext(): void {
clearRefreshTimer()
currentWorkspace.value = null
workspaceToken.value = null
error.value = null
clearSessionStorage()
}
onUnmounted(() => {
clearRefreshTimer()
})
return {
currentWorkspace,
workspaceToken,
isLoading,
error,
isAuthenticated,
initializeFromSession,
switchWorkspace,
refreshToken,
getWorkspaceAuthHeader,
clearWorkspaceContext
}
}

View File

@@ -0,0 +1,155 @@
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
const mockSwitchWorkspace = vi.fn()
const mockCurrentWorkspace = ref<{ id: string } | null>(null)
vi.mock('@/platform/auth/workspace/useWorkspaceAuth', () => ({
useWorkspaceAuth: () => ({
currentWorkspace: mockCurrentWorkspace,
switchWorkspace: mockSwitchWorkspace
})
}))
const mockActiveWorkflow = ref<{ isModified: boolean } | null>(null)
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: mockActiveWorkflow.value
})
}))
const mockConfirm = vi.fn()
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
confirm: mockConfirm
})
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
const mockReload = vi.fn()
describe('useWorkspaceSwitch', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCurrentWorkspace.value = { id: 'workspace-1' }
mockActiveWorkflow.value = null
Object.defineProperty(window, 'location', {
value: { reload: mockReload },
writable: true
})
})
describe('hasUnsavedChanges', () => {
it('returns true when activeWorkflow.isModified is true', () => {
mockActiveWorkflow.value = { isModified: true }
const { hasUnsavedChanges } = useWorkspaceSwitch()
expect(hasUnsavedChanges()).toBe(true)
})
it('returns false when activeWorkflow.isModified is false', () => {
mockActiveWorkflow.value = { isModified: false }
const { hasUnsavedChanges } = useWorkspaceSwitch()
expect(hasUnsavedChanges()).toBe(false)
})
it('returns false when activeWorkflow is null', () => {
mockActiveWorkflow.value = null
const { hasUnsavedChanges } = useWorkspaceSwitch()
expect(hasUnsavedChanges()).toBe(false)
})
})
describe('switchWithConfirmation', () => {
it('returns true immediately if switching to the same workspace', async () => {
mockCurrentWorkspace.value = { id: 'workspace-1' }
const { switchWithConfirmation } = useWorkspaceSwitch()
const result = await switchWithConfirmation('workspace-1')
expect(result).toBe(true)
expect(mockSwitchWorkspace).not.toHaveBeenCalled()
expect(mockConfirm).not.toHaveBeenCalled()
})
it('switches directly without dialog when no unsaved changes', async () => {
mockCurrentWorkspace.value = { id: 'workspace-1' }
mockActiveWorkflow.value = { isModified: false }
mockSwitchWorkspace.mockResolvedValue(undefined)
const { switchWithConfirmation } = useWorkspaceSwitch()
const result = await switchWithConfirmation('workspace-2')
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 () => {
mockCurrentWorkspace.value = { id: 'workspace-1' }
mockActiveWorkflow.value = { isModified: true }
mockConfirm.mockResolvedValue(true)
mockSwitchWorkspace.mockResolvedValue(undefined)
const { switchWithConfirmation } = useWorkspaceSwitch()
await switchWithConfirmation('workspace-2')
expect(mockConfirm).toHaveBeenCalledWith({
title: 'workspace.unsavedChanges.title',
message: 'workspace.unsavedChanges.message',
type: 'dirtyClose'
})
})
it('returns false if user cancels the confirmation dialog', async () => {
mockCurrentWorkspace.value = { id: 'workspace-1' }
mockActiveWorkflow.value = { isModified: true }
mockConfirm.mockResolvedValue(false)
const { switchWithConfirmation } = useWorkspaceSwitch()
const result = await switchWithConfirmation('workspace-2')
expect(result).toBe(false)
expect(mockSwitchWorkspace).not.toHaveBeenCalled()
expect(mockReload).not.toHaveBeenCalled()
})
it('calls switchWorkspace and reloads page after user confirms', async () => {
mockCurrentWorkspace.value = { id: 'workspace-1' }
mockActiveWorkflow.value = { isModified: true }
mockConfirm.mockResolvedValue(true)
mockSwitchWorkspace.mockResolvedValue(undefined)
const { switchWithConfirmation } = useWorkspaceSwitch()
const result = await switchWithConfirmation('workspace-2')
expect(result).toBe(true)
expect(mockSwitchWorkspace).toHaveBeenCalledWith('workspace-2')
expect(mockReload).toHaveBeenCalled()
})
it('returns false if switchWorkspace throws an error', async () => {
mockCurrentWorkspace.value = { id: 'workspace-1' }
mockActiveWorkflow.value = { isModified: false }
mockSwitchWorkspace.mockRejectedValue(new Error('Switch failed'))
const { switchWithConfirmation } = useWorkspaceSwitch()
const result = await switchWithConfirmation('workspace-2')
expect(result).toBe(false)
expect(mockReload).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,47 @@
import { useI18n } from 'vue-i18n'
import { useWorkspaceAuth } from '@/platform/auth/workspace/useWorkspaceAuth'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
export function useWorkspaceSwitch() {
const { t } = useI18n()
const workspaceAuth = useWorkspaceAuth()
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()
function hasUnsavedChanges(): boolean {
return workflowStore.activeWorkflow?.isModified ?? false
}
async function switchWithConfirmation(workspaceId: string): Promise<boolean> {
if (workspaceAuth.currentWorkspace.value?.id === workspaceId) {
return true
}
if (hasUnsavedChanges()) {
const confirmed = await dialogService.confirm({
title: t('workspace.unsavedChanges.title'),
message: t('workspace.unsavedChanges.message'),
type: 'dirtyClose'
})
if (!confirmed) {
return false
}
}
try {
await workspaceAuth.switchWorkspace(workspaceId)
window.location.reload()
return true
} catch {
return false
}
}
return {
hasUnsavedChanges,
switchWithConfirmation
}
}

View File

@@ -152,16 +152,28 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
/**
* Retrieves the appropriate authentication header for API requests.
* Checks for authentication in the following order:
* 1. Firebase authentication token (if user is logged in)
* 2. API key (if stored in the browser's credential manager)
* 1. Workspace token (if user has active workspace context)
* 2. Firebase authentication token (if user is logged in)
* 3. API key (if stored in the browser's credential manager)
*
* @returns {Promise<AuthHeader | null>}
* - A LoggedInAuthHeader with Bearer token if Firebase authenticated
* - A LoggedInAuthHeader with Bearer token (workspace or Firebase)
* - An ApiKeyAuthHeader with X-API-KEY if API key exists
* - null if neither authentication method is available
* - null if no authentication method is available
*/
const getAuthHeader = async (): Promise<AuthHeader | null> => {
// If available, set header with JWT used to identify the user to Firebase service
const workspaceToken = sessionStorage.getItem('Comfy.Workspace.Token')
const expiresAt = sessionStorage.getItem('Comfy.Workspace.ExpiresAt')
if (workspaceToken && expiresAt) {
const expiryTime = parseInt(expiresAt, 10)
if (Date.now() < expiryTime) {
return {
Authorization: `Bearer ${workspaceToken}`
}
}
}
const token = await getIdToken()
if (token) {
return {
@@ -169,7 +181,6 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
}
}
// If not authenticated with Firebase, try falling back to API key if available
return useApiKeyAuthStore().getAuthHeader()
}

View File

@@ -0,0 +1,24 @@
export type WorkspaceRole = 'owner' | 'member'
export interface WorkspaceWithRole {
id: string
name: string
type: 'personal' | 'team'
role: WorkspaceRole
}
export interface WorkspaceTokenResponse {
token: string
expires_at: string
workspace: {
id: string
name: string
type: 'personal' | 'team'
}
role: WorkspaceRole
permissions: string[]
}
export interface ListWorkspacesResponse {
workspaces: WorkspaceWithRole[]
}