mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
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:
@@ -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'
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
127
src/platform/auth/workspace/components/WorkspaceSelector.vue
Normal file
127
src/platform/auth/workspace/components/WorkspaceSelector.vue
Normal 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>
|
||||
117
src/platform/auth/workspace/components/WorkspaceSwitcher.vue
Normal file
117
src/platform/auth/workspace/components/WorkspaceSwitcher.vue
Normal 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>
|
||||
584
src/platform/auth/workspace/useWorkspaceAuth.test.ts
Normal file
584
src/platform/auth/workspace/useWorkspaceAuth.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
243
src/platform/auth/workspace/useWorkspaceAuth.ts
Normal file
243
src/platform/auth/workspace/useWorkspaceAuth.ts
Normal 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
|
||||
}
|
||||
}
|
||||
155
src/platform/auth/workspace/useWorkspaceSwitch.test.ts
Normal file
155
src/platform/auth/workspace/useWorkspaceSwitch.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
47
src/platform/auth/workspace/useWorkspaceSwitch.ts
Normal file
47
src/platform/auth/workspace/useWorkspaceSwitch.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
24
src/types/workspaceTypes.ts
Normal file
24
src/types/workspaceTypes.ts
Normal 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[]
|
||||
}
|
||||
Reference in New Issue
Block a user