mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 18:52:19 +00:00
feat(workspace): add core flow integration
- Add teamWorkspacesEnabled feature flag with isCloud guard - Update firebaseAuthStore.getAuthHeader() to prioritize workspace token - Add getFirebaseAuthHeader() for user-scoped endpoints - Initialize workspace store in router guard before app loads - Load invite from URL in GraphCanvas after app initialization - Update CurrentUserButton to show workspace popover when enabled BREAKING: When teamWorkspacesEnabled flag is true: - API calls use workspace-scoped tokens - User menu shows workspace context - Settings panel shows workspace mode layout
This commit is contained in:
@@ -126,11 +126,13 @@ import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
|||||||
import { useCanvasDrop } from '@/composables/useCanvasDrop'
|
import { useCanvasDrop } from '@/composables/useCanvasDrop'
|
||||||
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
|
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
|
||||||
import { useCopy } from '@/composables/useCopy'
|
import { useCopy } from '@/composables/useCopy'
|
||||||
|
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||||
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
|
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
|
||||||
import { usePaste } from '@/composables/usePaste'
|
import { usePaste } from '@/composables/usePaste'
|
||||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||||
import { mergeCustomNodesI18n, t } from '@/i18n'
|
import { mergeCustomNodesI18n, t } from '@/i18n'
|
||||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||||
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
|
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
|
||||||
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
|
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
@@ -394,6 +396,7 @@ const loadCustomNodesI18n = async () => {
|
|||||||
|
|
||||||
const comfyAppReady = ref(false)
|
const comfyAppReady = ref(false)
|
||||||
const workflowPersistence = useWorkflowPersistence()
|
const workflowPersistence = useWorkflowPersistence()
|
||||||
|
const { flags } = useFeatureFlags()
|
||||||
useCanvasDrop(canvasRef)
|
useCanvasDrop(canvasRef)
|
||||||
useLitegraphSettings()
|
useLitegraphSettings()
|
||||||
useNodeBadge()
|
useNodeBadge()
|
||||||
@@ -459,6 +462,13 @@ onMounted(async () => {
|
|||||||
// Load template from URL if present
|
// Load template from URL if present
|
||||||
await workflowPersistence.loadTemplateFromUrlIfPresent()
|
await workflowPersistence.loadTemplateFromUrlIfPresent()
|
||||||
|
|
||||||
|
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
|
||||||
|
if (isCloud && flags.teamWorkspacesEnabled) {
|
||||||
|
const { useInviteUrlLoader } =
|
||||||
|
await import('@/platform/workspace/composables/useInviteUrlLoader')
|
||||||
|
await useInviteUrlLoader().loadInviteFromUrl()
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
|
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
|
||||||
const { useReleaseStore } =
|
const { useReleaseStore } =
|
||||||
await import('@/platform/updates/common/releaseStore')
|
await import('@/platform/updates/common/releaseStore')
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!-- A button that shows current authenticated user's avatar -->
|
<!-- A button that shows workspace icon (Cloud) or user avatar -->
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
@@ -16,7 +16,16 @@
|
|||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<UserAvatar :photo-url="photoURL" :class="compact && 'size-full'" />
|
<WorkspaceProfilePic
|
||||||
|
v-if="showWorkspaceIcon"
|
||||||
|
:workspace-name="workspaceName"
|
||||||
|
:class="compact && 'size-full'"
|
||||||
|
/>
|
||||||
|
<UserAvatar
|
||||||
|
v-else
|
||||||
|
:photo-url="photoURL"
|
||||||
|
:class="compact && 'size-full'"
|
||||||
|
/>
|
||||||
|
|
||||||
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-3 px-1" />
|
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-3 px-1" />
|
||||||
</div>
|
</div>
|
||||||
@@ -27,38 +36,65 @@
|
|||||||
:show-arrow="false"
|
:show-arrow="false"
|
||||||
:pt="{
|
:pt="{
|
||||||
root: {
|
root: {
|
||||||
class: 'rounded-lg'
|
class: 'rounded-lg w-80'
|
||||||
}
|
}
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<CurrentUserPopover @close="closePopover" />
|
<!-- Workspace mode: workspace-aware popover -->
|
||||||
|
<CurrentUserPopoverWorkspace
|
||||||
|
v-if="teamWorkspacesEnabled"
|
||||||
|
@close="closePopover"
|
||||||
|
/>
|
||||||
|
<!-- Legacy mode: original popover -->
|
||||||
|
<CurrentUserPopover v-else @close="closePopover" />
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
import Popover from 'primevue/popover'
|
import Popover from 'primevue/popover'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, defineAsyncComponent, ref } from 'vue'
|
||||||
|
|
||||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||||
|
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||||
|
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||||
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
|
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
import CurrentUserPopover from './CurrentUserPopover.vue'
|
import CurrentUserPopover from './CurrentUserPopover.vue'
|
||||||
|
|
||||||
|
const CurrentUserPopoverWorkspace = defineAsyncComponent(
|
||||||
|
() => import('./CurrentUserPopoverWorkspace.vue')
|
||||||
|
)
|
||||||
|
|
||||||
const { showArrow = true, compact = false } = defineProps<{
|
const { showArrow = true, compact = false } = defineProps<{
|
||||||
showArrow?: boolean
|
showArrow?: boolean
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { flags } = useFeatureFlags()
|
||||||
|
const teamWorkspacesEnabled = computed(() => flags.teamWorkspacesEnabled)
|
||||||
|
|
||||||
const { isLoggedIn, userPhotoUrl } = useCurrentUser()
|
const { isLoggedIn, userPhotoUrl } = useCurrentUser()
|
||||||
|
|
||||||
const popover = ref<InstanceType<typeof Popover> | null>(null)
|
|
||||||
const photoURL = computed<string | undefined>(
|
const photoURL = computed<string | undefined>(
|
||||||
() => userPhotoUrl.value ?? undefined
|
() => userPhotoUrl.value ?? undefined
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const showWorkspaceIcon = computed(() => isCloud && teamWorkspacesEnabled.value)
|
||||||
|
|
||||||
|
const workspaceName = computed(() => {
|
||||||
|
if (!showWorkspaceIcon.value) return ''
|
||||||
|
const { workspaceName } = storeToRefs(useTeamWorkspaceStore())
|
||||||
|
return workspaceName.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const popover = ref<InstanceType<typeof Popover> | null>(null)
|
||||||
|
|
||||||
const closePopover = () => {
|
const closePopover = () => {
|
||||||
popover.value?.hide()
|
popover.value?.hide()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { computed, reactive, readonly } from 'vue'
|
import { computed, reactive, readonly } from 'vue'
|
||||||
|
|
||||||
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
|
|
||||||
@@ -95,6 +96,8 @@ export function useFeatureFlags() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
get teamWorkspacesEnabled() {
|
get teamWorkspacesEnabled() {
|
||||||
|
if (!isCloud) return false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
remoteConfig.value.team_workspaces_enabled ??
|
remoteConfig.value.team_workspaces_enabled ??
|
||||||
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
|
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ installPreservedQueryTracker(router, [
|
|||||||
{
|
{
|
||||||
namespace: PRESERVED_QUERY_NAMESPACES.TEMPLATE,
|
namespace: PRESERVED_QUERY_NAMESPACES.TEMPLATE,
|
||||||
keys: ['template', 'source', 'mode']
|
keys: ['template', 'source', 'mode']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
namespace: PRESERVED_QUERY_NAMESPACES.INVITE,
|
||||||
|
keys: ['invite']
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -178,6 +182,24 @@ if (isCloud) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize workspace context for logged-in users navigating to root
|
||||||
|
// This must happen before the app loads to ensure workspace context is ready
|
||||||
|
|
||||||
|
if (to.path === '/' && flags.teamWorkspacesEnabled) {
|
||||||
|
const { useTeamWorkspaceStore } =
|
||||||
|
await import('@/platform/workspace/stores/teamWorkspaceStore')
|
||||||
|
const workspaceStore = useTeamWorkspaceStore()
|
||||||
|
|
||||||
|
if (workspaceStore.initState === 'uninitialized') {
|
||||||
|
try {
|
||||||
|
await workspaceStore.initialize()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Workspace initialization failed:', error)
|
||||||
|
// Continue anyway - workspace features will be degraded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// User is logged in - check if they need onboarding (when enabled)
|
// User is logged in - check if they need onboarding (when enabled)
|
||||||
// For root path, check actual user status to handle waitlisted users
|
// For root path, check actual user status to handle waitlisted users
|
||||||
if (!isElectron() && isLoggedIn && to.path === '/') {
|
if (!isElectron() && isLoggedIn && to.path === '/') {
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
|||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
|
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
|
||||||
import { useTelemetry } from '@/platform/telemetry'
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
import { useDialogService } from '@/services/dialogService'
|
import { useDialogService } from '@/services/dialogService'
|
||||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||||
import type { AuthHeader } from '@/types/authTypes'
|
import type { AuthHeader } from '@/types/authTypes'
|
||||||
import type { operations } from '@/types/comfyRegistryTypes'
|
import type { operations } from '@/types/comfyRegistryTypes'
|
||||||
|
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||||
|
|
||||||
type CreditPurchaseResponse =
|
type CreditPurchaseResponse =
|
||||||
operations['InitiateCreditPurchase']['responses']['201']['content']['application/json']
|
operations['InitiateCreditPurchase']['responses']['201']['content']['application/json']
|
||||||
@@ -58,6 +58,8 @@ export class FirebaseAuthStoreError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||||
|
const { flags } = useFeatureFlags()
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const currentUser = ref<User | null>(null)
|
const currentUser = ref<User | null>(null)
|
||||||
@@ -173,7 +175,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
|||||||
* - null if no authentication method is available
|
* - null if no authentication method is available
|
||||||
*/
|
*/
|
||||||
const getAuthHeader = async (): Promise<AuthHeader | null> => {
|
const getAuthHeader = async (): Promise<AuthHeader | null> => {
|
||||||
if (remoteConfig.value.team_workspaces_enabled) {
|
if (flags.teamWorkspacesEnabled) {
|
||||||
const workspaceToken = sessionStorage.getItem(
|
const workspaceToken = sessionStorage.getItem(
|
||||||
WORKSPACE_STORAGE_KEYS.TOKEN
|
WORKSPACE_STORAGE_KEYS.TOKEN
|
||||||
)
|
)
|
||||||
@@ -201,10 +203,19 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
|||||||
return useApiKeyAuthStore().getAuthHeader()
|
return useApiKeyAuthStore().getAuthHeader()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns Firebase auth header for user-scoped endpoints (e.g., /customers/*).
|
||||||
|
* Use this for endpoints that need user identity, not workspace context.
|
||||||
|
*/
|
||||||
|
const getFirebaseAuthHeader = async (): Promise<AuthHeader | null> => {
|
||||||
|
const token = await getIdToken()
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : null
|
||||||
|
}
|
||||||
|
|
||||||
const fetchBalance = async (): Promise<GetCustomerBalanceResponse | null> => {
|
const fetchBalance = async (): Promise<GetCustomerBalanceResponse | null> => {
|
||||||
isFetchingBalance.value = true
|
isFetchingBalance.value = true
|
||||||
try {
|
try {
|
||||||
const authHeader = await getAuthHeader()
|
const authHeader = await getFirebaseAuthHeader()
|
||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
throw new FirebaseAuthStoreError(
|
throw new FirebaseAuthStoreError(
|
||||||
t('toastMessages.userNotAuthenticated')
|
t('toastMessages.userNotAuthenticated')
|
||||||
@@ -242,7 +253,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createCustomer = async (): Promise<CreateCustomerResponse> => {
|
const createCustomer = async (): Promise<CreateCustomerResponse> => {
|
||||||
const authHeader = await getAuthHeader()
|
const authHeader = await getFirebaseAuthHeader()
|
||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||||
}
|
}
|
||||||
@@ -404,7 +415,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
|||||||
const addCredits = async (
|
const addCredits = async (
|
||||||
requestBodyContent: CreditPurchasePayload
|
requestBodyContent: CreditPurchasePayload
|
||||||
): Promise<CreditPurchaseResponse> => {
|
): Promise<CreditPurchaseResponse> => {
|
||||||
const authHeader = await getAuthHeader()
|
const authHeader = await getFirebaseAuthHeader()
|
||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||||
}
|
}
|
||||||
@@ -444,21 +455,19 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
|||||||
const accessBillingPortal = async (
|
const accessBillingPortal = async (
|
||||||
targetTier?: BillingPortalTargetTier
|
targetTier?: BillingPortalTargetTier
|
||||||
): Promise<AccessBillingPortalResponse> => {
|
): Promise<AccessBillingPortalResponse> => {
|
||||||
const authHeader = await getAuthHeader()
|
const authHeader = await getFirebaseAuthHeader()
|
||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestBody = targetTier ? { target_tier: targetTier } : undefined
|
|
||||||
|
|
||||||
const response = await fetch(buildApiUrl('/customers/billing'), {
|
const response = await fetch(buildApiUrl('/customers/billing'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
...authHeader,
|
...authHeader,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
...(requestBody && {
|
...(targetTier && {
|
||||||
body: JSON.stringify(requestBody)
|
body: JSON.stringify({ target_tier: targetTier })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
<GlobalToast />
|
<GlobalToast />
|
||||||
<RerouteMigrationToast />
|
<RerouteMigrationToast />
|
||||||
|
<WorkspaceCreatedToast />
|
||||||
<ModelImportProgressDialog />
|
<ModelImportProgressDialog />
|
||||||
<ManagerProgressToast />
|
<ManagerProgressToast />
|
||||||
<UnloadWindowConfirmDialog v-if="!isElectron()" />
|
<UnloadWindowConfirmDialog v-if="!isElectron()" />
|
||||||
@@ -45,6 +46,7 @@ import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDi
|
|||||||
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
|
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
|
||||||
import GlobalToast from '@/components/toast/GlobalToast.vue'
|
import GlobalToast from '@/components/toast/GlobalToast.vue'
|
||||||
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'
|
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'
|
||||||
|
import WorkspaceCreatedToast from '@/components/toast/WorkspaceCreatedToast.vue'
|
||||||
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
|
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
|
||||||
import { useCoreCommands } from '@/composables/useCoreCommands'
|
import { useCoreCommands } from '@/composables/useCoreCommands'
|
||||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||||
|
|||||||
Reference in New Issue
Block a user