mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 10:42:44 +00:00
feat: FF the feature and also added isCloud checks and misc styling fixes
This commit is contained in:
@@ -6,7 +6,9 @@
|
|||||||
v-model:visible="item.visible"
|
v-model:visible="item.visible"
|
||||||
:class="[
|
:class="[
|
||||||
'global-dialog',
|
'global-dialog',
|
||||||
item.key === 'global-settings' ? 'settings-dialog' : ''
|
item.key === 'global-settings' && teamWorkspacesEnabled
|
||||||
|
? 'settings-dialog-workspace'
|
||||||
|
: ''
|
||||||
]"
|
]"
|
||||||
v-bind="item.dialogComponentProps"
|
v-bind="item.dialogComponentProps"
|
||||||
:pt="item.dialogComponentProps.pt"
|
:pt="item.dialogComponentProps.pt"
|
||||||
@@ -41,8 +43,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Dialog from 'primevue/dialog'
|
import Dialog from 'primevue/dialog'
|
||||||
|
|
||||||
|
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||||
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { useDialogStore } from '@/stores/dialogStore'
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
|
|
||||||
|
const { flags } = useFeatureFlags()
|
||||||
|
const teamWorkspacesEnabled = isCloud && flags.teamWorkspacesEnabled
|
||||||
|
|
||||||
const dialogStore = useDialogStore()
|
const dialogStore = useDialogStore()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -59,12 +66,13 @@ const dialogStore = useDialogStore()
|
|||||||
@apply pt-0;
|
@apply pt-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-dialog {
|
/* Workspace mode: wider settings dialog */
|
||||||
|
.settings-dialog-workspace {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1440px;
|
max-width: 1440px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-dialog .p-dialog-content {
|
.settings-dialog-workspace .p-dialog-content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<TabPanel value="Profile" class="user-settings-container h-full">
|
<TabPanel value="User" class="user-settings-container h-full">
|
||||||
<div class="flex h-full flex-col">
|
<div class="flex h-full flex-col">
|
||||||
<h2 class="mb-2 text-2xl font-bold">{{ $t('userSettings.title') }}</h2>
|
<h2 class="mb-2 text-2xl font-bold">{{ $t('userSettings.title') }}</h2>
|
||||||
<Divider class="mb-3" />
|
<Divider class="mb-3" />
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||||
import MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
|
import MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import SubscriptionPanelContent from '@/platform/cloud/subscription/components/SubscriptionPanelContent.vue'
|
import SubscriptionPanelContent from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
|
||||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||||
import { useDialogService } from '@/services/dialogService'
|
import { useDialogService } from '@/services/dialogService'
|
||||||
@@ -111,7 +111,8 @@ const { t } = useI18n()
|
|||||||
const {
|
const {
|
||||||
showLeaveWorkspaceDialog,
|
showLeaveWorkspaceDialog,
|
||||||
showDeleteWorkspaceDialog,
|
showDeleteWorkspaceDialog,
|
||||||
showInviteMemberDialog
|
showInviteMemberDialog,
|
||||||
|
showEditWorkspaceDialog
|
||||||
} = useDialogService()
|
} = useDialogService()
|
||||||
const workspaceStore = useTeamWorkspaceStore()
|
const workspaceStore = useTeamWorkspaceStore()
|
||||||
const { workspaceName, members, isInviteLimitReached, isWorkspaceSubscribed } =
|
const { workspaceName, members, isInviteLimitReached, isWorkspaceSubscribed } =
|
||||||
@@ -130,6 +131,10 @@ function handleDeleteWorkspace() {
|
|||||||
showDeleteWorkspaceDialog()
|
showDeleteWorkspaceDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleEditWorkspace() {
|
||||||
|
showEditWorkspaceDialog()
|
||||||
|
}
|
||||||
|
|
||||||
// Disable delete when workspace has an active subscription (to prevent accidental deletion)
|
// Disable delete when workspace has an active subscription (to prevent accidental deletion)
|
||||||
// Use workspace's own subscription status, not the global isActiveSubscription
|
// Use workspace's own subscription status, not the global isActiveSubscription
|
||||||
const isDeleteDisabled = computed(
|
const isDeleteDisabled = computed(
|
||||||
@@ -157,30 +162,37 @@ function handleInviteMember() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const menuItems = computed(() => {
|
const menuItems = computed(() => {
|
||||||
const action = uiConfig.value.workspaceMenuAction
|
const items = []
|
||||||
if (!action) return []
|
|
||||||
|
|
||||||
if (action === 'delete') {
|
// Add edit option for owners
|
||||||
return [
|
if (uiConfig.value.showEditWorkspaceMenuItem) {
|
||||||
{
|
items.push({
|
||||||
label: t('workspacePanel.menu.deleteWorkspace'),
|
label: t('workspacePanel.menu.editWorkspace'),
|
||||||
icon: 'pi pi-trash',
|
icon: 'pi pi-pencil',
|
||||||
class: isDeleteDisabled.value
|
command: handleEditWorkspace
|
||||||
? 'text-danger/50 cursor-not-allowed'
|
})
|
||||||
: 'text-danger',
|
|
||||||
disabled: isDeleteDisabled.value,
|
|
||||||
command: isDeleteDisabled.value ? undefined : handleDeleteWorkspace
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
const action = uiConfig.value.workspaceMenuAction
|
||||||
{
|
if (action === 'delete') {
|
||||||
|
items.push({
|
||||||
|
label: t('workspacePanel.menu.deleteWorkspace'),
|
||||||
|
icon: 'pi pi-trash',
|
||||||
|
class: isDeleteDisabled.value
|
||||||
|
? 'text-danger/50 cursor-not-allowed'
|
||||||
|
: 'text-danger',
|
||||||
|
disabled: isDeleteDisabled.value,
|
||||||
|
command: isDeleteDisabled.value ? undefined : handleDeleteWorkspace
|
||||||
|
})
|
||||||
|
} else if (action === 'leave') {
|
||||||
|
items.push({
|
||||||
label: t('workspacePanel.menu.leaveWorkspace'),
|
label: t('workspacePanel.menu.leaveWorkspace'),
|
||||||
icon: 'pi pi-sign-out',
|
icon: 'pi pi-sign-out',
|
||||||
command: handleLeaveWorkspace
|
command: handleLeaveWorkspace
|
||||||
}
|
})
|
||||||
]
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -139,7 +141,6 @@ import { useWorkflowService } from '@/platform/workflow/core/services/workflowSe
|
|||||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||||
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
|
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
|
||||||
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
|
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
|
||||||
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
|
|
||||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||||
@@ -395,7 +396,7 @@ const loadCustomNodesI18n = async () => {
|
|||||||
|
|
||||||
const comfyAppReady = ref(false)
|
const comfyAppReady = ref(false)
|
||||||
const workflowPersistence = useWorkflowPersistence()
|
const workflowPersistence = useWorkflowPersistence()
|
||||||
const inviteUrlLoader = useInviteUrlLoader()
|
const { flags } = useFeatureFlags()
|
||||||
useCanvasDrop(canvasRef)
|
useCanvasDrop(canvasRef)
|
||||||
useLitegraphSettings()
|
useLitegraphSettings()
|
||||||
useNodeBadge()
|
useNodeBadge()
|
||||||
@@ -462,7 +463,11 @@ onMounted(async () => {
|
|||||||
await workflowPersistence.loadTemplateFromUrlIfPresent()
|
await workflowPersistence.loadTemplateFromUrlIfPresent()
|
||||||
|
|
||||||
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
|
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
|
||||||
await inviteUrlLoader.loadInviteFromUrl()
|
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 } =
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!-- A button that shows current workspace's profile picture -->
|
<!-- A button that shows workspace icon (Cloud) or user avatar -->
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
@@ -17,9 +17,15 @@
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
<WorkspaceProfilePic
|
<WorkspaceProfilePic
|
||||||
|
v-if="showWorkspaceIcon"
|
||||||
:workspace-name="workspaceName"
|
:workspace-name="workspaceName"
|
||||||
:class="compact && 'size-full'"
|
: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>
|
||||||
@@ -30,11 +36,17 @@
|
|||||||
: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>
|
||||||
@@ -42,23 +54,44 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import Popover from 'primevue/popover'
|
import Popover from 'primevue/popover'
|
||||||
import { ref } from 'vue'
|
import { computed, defineAsyncComponent, ref } from 'vue'
|
||||||
|
|
||||||
|
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.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 { 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 { isLoggedIn } = useCurrentUser()
|
const { flags } = useFeatureFlags()
|
||||||
const { workspaceName } = storeToRefs(useTeamWorkspaceStore())
|
const teamWorkspacesEnabled = computed(() => flags.teamWorkspacesEnabled)
|
||||||
|
|
||||||
|
const { isLoggedIn, userPhotoUrl } = useCurrentUser()
|
||||||
|
|
||||||
|
const photoURL = computed<string | 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 popover = ref<InstanceType<typeof Popover> | null>(null)
|
||||||
|
|
||||||
|
|||||||
@@ -21,197 +21,114 @@
|
|||||||
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
|
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
|
||||||
{{ userEmail }}
|
{{ userEmail }}
|
||||||
</p>
|
</p>
|
||||||
<!-- <span
|
<span
|
||||||
v-if="subscriptionTierName"
|
v-if="subscriptionTierName"
|
||||||
class="my-0 text-xs text-foreground bg-secondary-background-hover rounded-full uppercase px-2 py-0.5 font-bold mt-2"
|
class="my-0 text-xs text-foreground bg-secondary-background-hover rounded-full uppercase px-2 py-0.5 font-bold mt-2"
|
||||||
>
|
>
|
||||||
{{ subscriptionTierName }}
|
{{ subscriptionTierName }}
|
||||||
</span> -->
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Workspace Selector -->
|
<!-- Credits Section -->
|
||||||
<div
|
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
|
||||||
class="flex cursor-pointer items-center justify-between rounded-lg px-4 py-2 hover:bg-secondary-background-hover"
|
<i class="icon-[lucide--component] text-amber-400 text-sm" />
|
||||||
@click="toggleWorkspaceSwitcher"
|
<Skeleton
|
||||||
>
|
v-if="authStore.isFetchingBalance"
|
||||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
width="4rem"
|
||||||
<WorkspaceProfilePic
|
height="1.25rem"
|
||||||
class="size-6 shrink-0 text-xs"
|
class="w-full"
|
||||||
:workspace-name="workspaceName"
|
|
||||||
/>
|
|
||||||
<span class="truncate text-sm text-base-foreground">{{
|
|
||||||
workspaceName
|
|
||||||
}}</span>
|
|
||||||
<div
|
|
||||||
v-if="workspaceTierName"
|
|
||||||
class="shrink-0 rounded bg-secondary-background-hover px-1.5 py-0.5 text-xs"
|
|
||||||
>
|
|
||||||
{{ workspaceTierName }}
|
|
||||||
</div>
|
|
||||||
<span v-else class="shrink-0 text-xs text-muted-foreground">
|
|
||||||
{{ $t('workspaceSwitcher.subscribe') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<i class="pi pi-chevron-down shrink-0 text-sm text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Popover
|
|
||||||
ref="workspaceSwitcherPopover"
|
|
||||||
append-to="body"
|
|
||||||
:pt="{
|
|
||||||
content: {
|
|
||||||
class: 'p-0'
|
|
||||||
}
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<WorkspaceSwitcherPopover
|
|
||||||
@select="workspaceSwitcherPopover?.hide()"
|
|
||||||
@create="handleCreateWorkspace"
|
|
||||||
/>
|
/>
|
||||||
</Popover>
|
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||||
|
formattedBalance
|
||||||
<!-- Credits Section (PERSONAL and OWNER only) -->
|
}}</span>
|
||||||
<template v-if="showCreditsSection">
|
<i
|
||||||
<!-- Subscribed: Show balance + Add credits -->
|
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
|
||||||
<div
|
class="icon-[lucide--circle-help] cursor-help text-base text-muted-foreground mr-auto"
|
||||||
v-if="isActiveSubscription && isWorkspaceSubscribed"
|
/>
|
||||||
class="flex items-center gap-2 px-4 py-2"
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
class="text-base-foreground"
|
||||||
|
data-testid="add-credits-button"
|
||||||
|
@click="handleTopUp"
|
||||||
>
|
>
|
||||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
{{ $t('subscription.addCredits') }}
|
||||||
<Skeleton
|
</Button>
|
||||||
v-if="authStore.isFetchingBalance"
|
</div>
|
||||||
width="4rem"
|
|
||||||
height="1.25rem"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
|
||||||
formattedBalance
|
|
||||||
}}</span>
|
|
||||||
<i
|
|
||||||
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
|
|
||||||
class="icon-[lucide--circle-help] mr-auto cursor-help text-base text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
class="text-base-foreground"
|
|
||||||
data-testid="add-credits-button"
|
|
||||||
@click="handleTopUp"
|
|
||||||
>
|
|
||||||
{{ $t('subscription.addCredits') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- OWNER unsubscribed: Show Subscribe button (primary) -->
|
<div v-else class="flex justify-center px-4">
|
||||||
<div
|
<SubscribeButton
|
||||||
v-else-if="workspaceRole === 'owner' && !isWorkspaceSubscribed"
|
:fluid="false"
|
||||||
class="flex justify-center px-4 py-2"
|
:label="$t('subscription.subscribeToComfyCloud')"
|
||||||
>
|
size="sm"
|
||||||
<Button
|
variant="gradient"
|
||||||
variant="primary"
|
@subscribed="handleSubscribed"
|
||||||
size="sm"
|
/>
|
||||||
class="w-full"
|
</div>
|
||||||
data-testid="subscribe-button"
|
|
||||||
@click="handleOpenWorkspaceSettings"
|
|
||||||
>
|
|
||||||
{{ $t('subscription.subscribeNow') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- PERSONAL unsubscribed: Show gradient SubscribeButton -->
|
<Divider class="my-2 mx-0" />
|
||||||
<div v-else class="flex justify-center px-4">
|
|
||||||
<SubscribeButton
|
|
||||||
:fluid="false"
|
|
||||||
:label="$t('subscription.subscribeToComfyCloud')"
|
|
||||||
size="sm"
|
|
||||||
variant="gradient"
|
|
||||||
@subscribed="handleSubscribed"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Divider class="mx-0 my-2" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Plans & Pricing (PERSONAL and OWNER only) -->
|
|
||||||
<div
|
<div
|
||||||
v-if="showPlansAndPricing"
|
v-if="isActiveSubscription"
|
||||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
|
||||||
|
data-testid="partner-nodes-menu-item"
|
||||||
|
@click="handleOpenPartnerNodesInfo"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--tag] text-muted-foreground text-sm" />
|
||||||
|
<span class="text-sm text-base-foreground flex-1">{{
|
||||||
|
$t('subscription.partnerNodesCredits')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
|
||||||
data-testid="plans-pricing-menu-item"
|
data-testid="plans-pricing-menu-item"
|
||||||
@click="handleOpenPlansAndPricing"
|
@click="handleOpenPlansAndPricing"
|
||||||
>
|
>
|
||||||
<i class="icon-[lucide--receipt-text] text-sm text-muted-foreground" />
|
<i class="icon-[lucide--receipt-text] text-muted-foreground text-sm" />
|
||||||
<span class="flex-1 text-sm text-base-foreground">{{
|
<span class="text-sm text-base-foreground flex-1">{{
|
||||||
$t('subscription.plansAndPricing')
|
$t('subscription.plansAndPricing')
|
||||||
}}</span>
|
}}</span>
|
||||||
<span
|
<span
|
||||||
v-if="canUpgrade"
|
v-if="canUpgrade"
|
||||||
class="rounded-full bg-base-foreground px-1.5 py-0.5 text-xs font-bold text-base-background"
|
class="text-xs font-bold text-base-background bg-base-foreground px-1.5 py-0.5 rounded-full"
|
||||||
>
|
>
|
||||||
{{ $t('subscription.upgrade') }}
|
{{ $t('subscription.upgrade') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Manage Plan (PERSONAL and OWNER, only if subscribed) -->
|
|
||||||
<div
|
<div
|
||||||
v-if="showManagePlan"
|
v-if="isActiveSubscription"
|
||||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
|
||||||
data-testid="manage-plan-menu-item"
|
data-testid="manage-plan-menu-item"
|
||||||
@click="handleOpenPlanAndCreditsSettings"
|
@click="handleOpenPlanAndCreditsSettings"
|
||||||
>
|
>
|
||||||
<i class="icon-[lucide--file-text] text-sm text-muted-foreground" />
|
<i class="icon-[lucide--file-text] text-muted-foreground text-sm" />
|
||||||
<span class="flex-1 text-sm text-base-foreground">{{
|
<span class="text-sm text-base-foreground flex-1">{{
|
||||||
$t('subscription.managePlan')
|
$t('subscription.managePlan')
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Partner Nodes Pricing (always shown) -->
|
|
||||||
<div
|
<div
|
||||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
|
||||||
data-testid="partner-nodes-menu-item"
|
|
||||||
@click="handleOpenPartnerNodesInfo"
|
|
||||||
>
|
|
||||||
<i class="icon-[lucide--tag] text-sm text-muted-foreground" />
|
|
||||||
<span class="flex-1 text-sm text-base-foreground">{{
|
|
||||||
$t('subscription.partnerNodesCredits')
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Divider class="mx-0 my-2" />
|
|
||||||
|
|
||||||
<!-- Workspace Settings (always shown) -->
|
|
||||||
<div
|
|
||||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
|
||||||
data-testid="workspace-settings-menu-item"
|
|
||||||
@click="handleOpenWorkspaceSettings"
|
|
||||||
>
|
|
||||||
<i class="icon-[lucide--users] text-sm text-muted-foreground" />
|
|
||||||
<span class="flex-1 text-sm text-base-foreground">{{
|
|
||||||
$t('userSettings.workspaceSettings')
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Account Settings (always shown) -->
|
|
||||||
<div
|
|
||||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
|
||||||
data-testid="user-settings-menu-item"
|
data-testid="user-settings-menu-item"
|
||||||
@click="handleOpenUserSettings"
|
@click="handleOpenUserSettings"
|
||||||
>
|
>
|
||||||
<i class="icon-[lucide--settings-2] text-sm text-muted-foreground" />
|
<i class="icon-[lucide--settings-2] text-muted-foreground text-sm" />
|
||||||
<span class="flex-1 text-sm text-base-foreground">{{
|
<span class="text-sm text-base-foreground flex-1">{{
|
||||||
$t('userSettings.accountSettings')
|
$t('userSettings.accountSettings')
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Divider class="mx-0 my-2" />
|
<Divider class="my-2 mx-0" />
|
||||||
|
|
||||||
<!-- Logout (always shown) -->
|
|
||||||
<div
|
<div
|
||||||
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
class="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-secondary-background-hover"
|
||||||
data-testid="logout-menu-item"
|
data-testid="logout-menu-item"
|
||||||
@click="handleLogout"
|
@click="handleLogout"
|
||||||
>
|
>
|
||||||
<i class="icon-[lucide--log-out] text-sm text-muted-foreground" />
|
<i class="icon-[lucide--log-out] text-muted-foreground text-sm" />
|
||||||
<span class="flex-1 text-sm text-base-foreground">{{
|
<span class="text-sm text-base-foreground flex-1">{{
|
||||||
$t('auth.signOut.signOut')
|
$t('auth.signOut.signOut')
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,17 +136,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { storeToRefs } from 'pinia'
|
|
||||||
import Divider from 'primevue/divider'
|
import Divider from 'primevue/divider'
|
||||||
import Popover from 'primevue/popover'
|
|
||||||
import Skeleton from 'primevue/skeleton'
|
import Skeleton from 'primevue/skeleton'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
|
||||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||||
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
|
||||||
import WorkspaceSwitcherPopover from '@/components/topbar/WorkspaceSwitcherPopover.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 { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||||
@@ -239,21 +152,9 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu
|
|||||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { useTelemetry } from '@/platform/telemetry'
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
|
||||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
|
||||||
import { useDialogService } from '@/services/dialogService'
|
import { useDialogService } from '@/services/dialogService'
|
||||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||||
|
|
||||||
const workspaceStore = useTeamWorkspaceStore()
|
|
||||||
const {
|
|
||||||
workspaceName,
|
|
||||||
isInPersonalWorkspace: isPersonalWorkspace,
|
|
||||||
isWorkspaceSubscribed,
|
|
||||||
subscriptionPlan
|
|
||||||
} = storeToRefs(workspaceStore)
|
|
||||||
const { workspaceRole } = useWorkspaceUI()
|
|
||||||
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
}>()
|
}>()
|
||||||
@@ -265,9 +166,14 @@ const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
|||||||
const authActions = useFirebaseAuthActions()
|
const authActions = useFirebaseAuthActions()
|
||||||
const authStore = useFirebaseAuthStore()
|
const authStore = useFirebaseAuthStore()
|
||||||
const dialogService = useDialogService()
|
const dialogService = useDialogService()
|
||||||
const { isActiveSubscription, fetchStatus } = useSubscription()
|
const {
|
||||||
|
isActiveSubscription,
|
||||||
|
subscriptionTierName,
|
||||||
|
subscriptionTier,
|
||||||
|
fetchStatus
|
||||||
|
} = useSubscription()
|
||||||
const subscriptionDialog = useSubscriptionDialog()
|
const subscriptionDialog = useSubscriptionDialog()
|
||||||
const { locale, t } = useI18n()
|
const { locale } = useI18n()
|
||||||
|
|
||||||
const formattedBalance = computed(() => {
|
const formattedBalance = computed(() => {
|
||||||
const cents =
|
const cents =
|
||||||
@@ -284,50 +190,18 @@ const formattedBalance = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Workspace subscription tier name (not user tier)
|
|
||||||
const workspaceTierName = computed(() => {
|
|
||||||
if (!isWorkspaceSubscribed.value) return null
|
|
||||||
if (!subscriptionPlan.value) return null
|
|
||||||
// Convert plan to display name
|
|
||||||
if (subscriptionPlan.value === 'PRO_MONTHLY')
|
|
||||||
return t('subscription.tiers.pro.name')
|
|
||||||
if (subscriptionPlan.value === 'PRO_YEARLY')
|
|
||||||
return t('subscription.tierNameYearly', {
|
|
||||||
name: t('subscription.tiers.pro.name')
|
|
||||||
})
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
|
|
||||||
const canUpgrade = computed(() => {
|
const canUpgrade = computed(() => {
|
||||||
// For workspace-based subscriptions, can upgrade if not on highest tier
|
const tier = subscriptionTier.value
|
||||||
return isWorkspaceSubscribed.value && subscriptionPlan.value !== null
|
return (
|
||||||
|
tier === 'FOUNDERS_EDITION' || tier === 'STANDARD' || tier === 'CREATOR'
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Menu visibility based on role
|
|
||||||
// PERSONAL: Plans & pricing, Manage plan (if subscribed), Partner nodes, Divider, Workspace settings, Account settings, Divider, Log out
|
|
||||||
// MEMBER: Partner nodes, Divider, Workspace settings, Account settings, Divider, Log out
|
|
||||||
// OWNER (unsubscribed): Plans & pricing, Partner nodes, Divider, Workspace settings, Account settings, Divider, Log out
|
|
||||||
// OWNER (subscribed): Plans & pricing, Manage plan, Partner nodes, Divider, Workspace settings, Account settings, Divider, Log out
|
|
||||||
const showPlansAndPricing = computed(
|
|
||||||
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
|
|
||||||
)
|
|
||||||
const showManagePlan = computed(
|
|
||||||
() => showPlansAndPricing.value && isActiveSubscription.value
|
|
||||||
)
|
|
||||||
const showCreditsSection = computed(
|
|
||||||
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleOpenUserSettings = () => {
|
const handleOpenUserSettings = () => {
|
||||||
dialogService.showSettingsDialog('user')
|
dialogService.showSettingsDialog('user')
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOpenWorkspaceSettings = () => {
|
|
||||||
dialogService.showSettingsDialog('workspace')
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOpenPlansAndPricing = () => {
|
const handleOpenPlansAndPricing = () => {
|
||||||
subscriptionDialog.show()
|
subscriptionDialog.show()
|
||||||
emit('close')
|
emit('close')
|
||||||
@@ -335,7 +209,7 @@ const handleOpenPlansAndPricing = () => {
|
|||||||
|
|
||||||
const handleOpenPlanAndCreditsSettings = () => {
|
const handleOpenPlanAndCreditsSettings = () => {
|
||||||
if (isCloud) {
|
if (isCloud) {
|
||||||
dialogService.showSettingsDialog('workspace')
|
dialogService.showSettingsDialog('subscription')
|
||||||
} else {
|
} else {
|
||||||
dialogService.showSettingsDialog('credits')
|
dialogService.showSettingsDialog('credits')
|
||||||
}
|
}
|
||||||
@@ -367,16 +241,6 @@ const handleSubscribed = async () => {
|
|||||||
await fetchStatus()
|
await fetchStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreateWorkspace = () => {
|
|
||||||
workspaceSwitcherPopover.value?.hide()
|
|
||||||
dialogService.showCreateWorkspaceDialog()
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleWorkspaceSwitcher = (event: MouseEvent) => {
|
|
||||||
workspaceSwitcherPopover.value?.toggle(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void authActions.fetchBalance()
|
void authActions.fetchBalance()
|
||||||
})
|
})
|
||||||
|
|||||||
346
src/components/topbar/CurrentUserPopoverWorkspace.vue
Normal file
346
src/components/topbar/CurrentUserPopoverWorkspace.vue
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
<!-- A popover that shows current user information and actions -->
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="current-user-popover w-80 -m-3 p-2 rounded-lg border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
|
||||||
|
>
|
||||||
|
<!-- User Info Section -->
|
||||||
|
<div class="flex flex-col items-center px-0 py-3 mb-4">
|
||||||
|
<UserAvatar
|
||||||
|
class="mb-1"
|
||||||
|
:photo-url="userPhotoUrl"
|
||||||
|
:pt:icon:class="{
|
||||||
|
'text-2xl!': !userPhotoUrl
|
||||||
|
}"
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- User Details -->
|
||||||
|
<h3 class="my-0 mb-1 truncate text-base font-bold text-base-foreground">
|
||||||
|
{{ userDisplayName || $t('g.user') }}
|
||||||
|
</h3>
|
||||||
|
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
|
||||||
|
{{ userEmail }}
|
||||||
|
</p>
|
||||||
|
<!-- <span
|
||||||
|
v-if="subscriptionTierName"
|
||||||
|
class="my-0 text-xs text-foreground bg-secondary-background-hover rounded-full uppercase px-2 py-0.5 font-bold mt-2"
|
||||||
|
>
|
||||||
|
{{ subscriptionTierName }}
|
||||||
|
</span> -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Workspace Selector -->
|
||||||
|
<div
|
||||||
|
class="flex cursor-pointer items-center justify-between rounded-lg px-4 py-2 hover:bg-secondary-background-hover"
|
||||||
|
@click="toggleWorkspaceSwitcher"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
<WorkspaceProfilePic
|
||||||
|
class="size-6 shrink-0 text-xs"
|
||||||
|
:workspace-name="workspaceName"
|
||||||
|
/>
|
||||||
|
<span class="truncate text-sm text-base-foreground">{{
|
||||||
|
workspaceName
|
||||||
|
}}</span>
|
||||||
|
<div
|
||||||
|
v-if="workspaceTierName"
|
||||||
|
class="shrink-0 rounded bg-secondary-background-hover px-1.5 py-0.5 text-xs"
|
||||||
|
>
|
||||||
|
{{ workspaceTierName }}
|
||||||
|
</div>
|
||||||
|
<span v-else class="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{{ $t('workspaceSwitcher.subscribe') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<i class="pi pi-chevron-down shrink-0 text-sm text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
ref="workspaceSwitcherPopover"
|
||||||
|
append-to="body"
|
||||||
|
:pt="{
|
||||||
|
content: {
|
||||||
|
class: 'p-0'
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<WorkspaceSwitcherPopover
|
||||||
|
@select="workspaceSwitcherPopover?.hide()"
|
||||||
|
@create="handleCreateWorkspace"
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<!-- Credits Section (PERSONAL and OWNER only) -->
|
||||||
|
<template v-if="showCreditsSection">
|
||||||
|
<div class="flex items-center gap-2 px-4 py-2">
|
||||||
|
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||||
|
<Skeleton
|
||||||
|
v-if="isLoadingBalance"
|
||||||
|
width="4rem"
|
||||||
|
height="1.25rem"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||||
|
displayedCredits
|
||||||
|
}}</span>
|
||||||
|
<i
|
||||||
|
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
|
||||||
|
class="icon-[lucide--circle-help] mr-auto cursor-help text-base text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<!-- Subscribed: Show Add Credits button -->
|
||||||
|
<Button
|
||||||
|
v-if="isActiveSubscription && isWorkspaceSubscribed"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
class="text-base-foreground"
|
||||||
|
data-testid="add-credits-button"
|
||||||
|
@click="handleTopUp"
|
||||||
|
>
|
||||||
|
{{ $t('subscription.addCredits') }}
|
||||||
|
</Button>
|
||||||
|
<!-- Unsubscribed: Show Subscribe button -->
|
||||||
|
<SubscribeButton
|
||||||
|
v-else
|
||||||
|
:fluid="false"
|
||||||
|
:label="$t('workspaceSwitcher.subscribe')"
|
||||||
|
size="sm"
|
||||||
|
variant="gradient"
|
||||||
|
@subscribed="handleSubscribed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider class="mx-0 my-2" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Plans & Pricing (PERSONAL and OWNER only) -->
|
||||||
|
<div
|
||||||
|
v-if="showPlansAndPricing"
|
||||||
|
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||||
|
data-testid="plans-pricing-menu-item"
|
||||||
|
@click="handleOpenPlansAndPricing"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--receipt-text] text-sm text-muted-foreground" />
|
||||||
|
<span class="flex-1 text-sm text-base-foreground">{{
|
||||||
|
$t('subscription.plansAndPricing')
|
||||||
|
}}</span>
|
||||||
|
<span
|
||||||
|
v-if="canUpgrade"
|
||||||
|
class="rounded-full bg-base-foreground px-1.5 py-0.5 text-xs font-bold text-base-background"
|
||||||
|
>
|
||||||
|
{{ $t('subscription.upgrade') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manage Plan (PERSONAL and OWNER, only if subscribed) -->
|
||||||
|
<div
|
||||||
|
v-if="showManagePlan"
|
||||||
|
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||||
|
data-testid="manage-plan-menu-item"
|
||||||
|
@click="handleOpenPlanAndCreditsSettings"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--file-text] text-sm text-muted-foreground" />
|
||||||
|
<span class="flex-1 text-sm text-base-foreground">{{
|
||||||
|
$t('subscription.managePlan')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Partner Nodes Pricing (always shown) -->
|
||||||
|
<div
|
||||||
|
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||||
|
data-testid="partner-nodes-menu-item"
|
||||||
|
@click="handleOpenPartnerNodesInfo"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--tag] text-sm text-muted-foreground" />
|
||||||
|
<span class="flex-1 text-sm text-base-foreground">{{
|
||||||
|
$t('subscription.partnerNodesCredits')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider class="mx-0 my-2" />
|
||||||
|
|
||||||
|
<!-- Workspace Settings (always shown) -->
|
||||||
|
<div
|
||||||
|
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||||
|
data-testid="workspace-settings-menu-item"
|
||||||
|
@click="handleOpenWorkspaceSettings"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--users] text-sm text-muted-foreground" />
|
||||||
|
<span class="flex-1 text-sm text-base-foreground">{{
|
||||||
|
$t('userSettings.workspaceSettings')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account Settings (always shown) -->
|
||||||
|
<div
|
||||||
|
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||||
|
data-testid="user-settings-menu-item"
|
||||||
|
@click="handleOpenUserSettings"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--settings-2] text-sm text-muted-foreground" />
|
||||||
|
<span class="flex-1 text-sm text-base-foreground">{{
|
||||||
|
$t('userSettings.accountSettings')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider class="mx-0 my-2" />
|
||||||
|
|
||||||
|
<!-- Logout (always shown) -->
|
||||||
|
<div
|
||||||
|
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
|
||||||
|
data-testid="logout-menu-item"
|
||||||
|
@click="handleLogout"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--log-out] text-sm text-muted-foreground" />
|
||||||
|
<span class="flex-1 text-sm text-base-foreground">{{
|
||||||
|
$t('auth.signOut.signOut')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import Divider from 'primevue/divider'
|
||||||
|
import Popover from 'primevue/popover'
|
||||||
|
import Skeleton from 'primevue/skeleton'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||||
|
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
|
||||||
|
import WorkspaceSwitcherPopover from '@/components/topbar/WorkspaceSwitcherPopover.vue'
|
||||||
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||||
|
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||||
|
import { useExternalLink } from '@/composables/useExternalLink'
|
||||||
|
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||||
|
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||||
|
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||||
|
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||||
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
|
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||||
|
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||||
|
import { useDialogService } from '@/services/dialogService'
|
||||||
|
|
||||||
|
const workspaceStore = useTeamWorkspaceStore()
|
||||||
|
const {
|
||||||
|
workspaceName,
|
||||||
|
isInPersonalWorkspace: isPersonalWorkspace,
|
||||||
|
isWorkspaceSubscribed,
|
||||||
|
subscriptionPlan
|
||||||
|
} = storeToRefs(workspaceStore)
|
||||||
|
const { workspaceRole } = useWorkspaceUI()
|
||||||
|
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||||
|
|
||||||
|
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
|
||||||
|
useCurrentUser()
|
||||||
|
const authActions = useFirebaseAuthActions()
|
||||||
|
const dialogService = useDialogService()
|
||||||
|
const { isActiveSubscription, fetchStatus } = useSubscription()
|
||||||
|
const { totalCredits, isLoadingBalance } = useSubscriptionCredits()
|
||||||
|
const subscriptionDialog = useSubscriptionDialog()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const displayedCredits = computed(() =>
|
||||||
|
isWorkspaceSubscribed.value ? totalCredits.value : '0'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Workspace subscription tier name (not user tier)
|
||||||
|
const workspaceTierName = computed(() => {
|
||||||
|
if (!isWorkspaceSubscribed.value) return null
|
||||||
|
if (!subscriptionPlan.value) return null
|
||||||
|
// Convert plan to display name
|
||||||
|
if (subscriptionPlan.value === 'PRO_MONTHLY')
|
||||||
|
return t('subscription.tiers.pro.name')
|
||||||
|
if (subscriptionPlan.value === 'PRO_YEARLY')
|
||||||
|
return t('subscription.tierNameYearly', {
|
||||||
|
name: t('subscription.tiers.pro.name')
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
const canUpgrade = computed(() => {
|
||||||
|
// For workspace-based subscriptions, can upgrade if not on highest tier
|
||||||
|
return isWorkspaceSubscribed.value && subscriptionPlan.value !== null
|
||||||
|
})
|
||||||
|
|
||||||
|
const showPlansAndPricing = computed(
|
||||||
|
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
|
||||||
|
)
|
||||||
|
const showManagePlan = computed(
|
||||||
|
() => showPlansAndPricing.value && isActiveSubscription.value
|
||||||
|
)
|
||||||
|
const showCreditsSection = computed(
|
||||||
|
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleOpenUserSettings = () => {
|
||||||
|
dialogService.showSettingsDialog('user')
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenWorkspaceSettings = () => {
|
||||||
|
dialogService.showSettingsDialog('workspace')
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenPlansAndPricing = () => {
|
||||||
|
subscriptionDialog.show()
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenPlanAndCreditsSettings = () => {
|
||||||
|
if (isCloud) {
|
||||||
|
dialogService.showSettingsDialog('workspace')
|
||||||
|
} else {
|
||||||
|
dialogService.showSettingsDialog('credits')
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTopUp = () => {
|
||||||
|
// Track purchase credits entry from avatar popover
|
||||||
|
useTelemetry()?.trackAddApiCreditButtonClicked()
|
||||||
|
dialogService.showTopUpCreditsDialog()
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenPartnerNodesInfo = () => {
|
||||||
|
window.open(
|
||||||
|
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
|
||||||
|
'_blank'
|
||||||
|
)
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await handleSignOut()
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubscribed = async () => {
|
||||||
|
await fetchStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateWorkspace = () => {
|
||||||
|
workspaceSwitcherPopover.value?.hide()
|
||||||
|
dialogService.showCreateWorkspaceDialog()
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleWorkspaceSwitcher = (event: MouseEvent) => {
|
||||||
|
workspaceSwitcherPopover.value?.toggle(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void authActions.fetchBalance()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -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,11 +96,12 @@ export function useFeatureFlags() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
get teamWorkspacesEnabled() {
|
get teamWorkspacesEnabled() {
|
||||||
return true
|
if (!isCloud) return false
|
||||||
// return (
|
|
||||||
// remoteConfig.value.team_workspaces_enabled ??
|
return (
|
||||||
// api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
|
remoteConfig.value.team_workspaces_enabled ??
|
||||||
// )
|
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2059,6 +2059,7 @@
|
|||||||
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
|
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
|
||||||
"workspaceNotSubscribed": "This workspace is not on a subscription",
|
"workspaceNotSubscribed": "This workspace is not on a subscription",
|
||||||
"subscriptionRequiredMessage": "A subscription is required for members to run workflows on Cloud",
|
"subscriptionRequiredMessage": "A subscription is required for members to run workflows on Cloud",
|
||||||
|
"contactOwnerToSubscribe": "Contact the workspace owner to subscribe",
|
||||||
"description": "Choose the best plan for you",
|
"description": "Choose the best plan for you",
|
||||||
"haveQuestions": "Have questions or wondering about enterprise?",
|
"haveQuestions": "Have questions or wondering about enterprise?",
|
||||||
"contactUs": "Contact us",
|
"contactUs": "Contact us",
|
||||||
@@ -2136,10 +2137,16 @@
|
|||||||
"createNewWorkspace": "create a new one."
|
"createNewWorkspace": "create a new one."
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
|
"editWorkspace": "Edit workspace details",
|
||||||
"leaveWorkspace": "Leave Workspace",
|
"leaveWorkspace": "Leave Workspace",
|
||||||
"deleteWorkspace": "Delete Workspace",
|
"deleteWorkspace": "Delete Workspace",
|
||||||
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first"
|
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first"
|
||||||
},
|
},
|
||||||
|
"editWorkspaceDialog": {
|
||||||
|
"title": "Edit workspace details",
|
||||||
|
"nameLabel": "Workspace name",
|
||||||
|
"save": "Save"
|
||||||
|
},
|
||||||
"leaveDialog": {
|
"leaveDialog": {
|
||||||
"title": "Leave this workspace?",
|
"title": "Leave this workspace?",
|
||||||
"message": "You won't be able to join again unless you contact the workspace owner.",
|
"message": "You won't be able to join again unless you contact the workspace owner.",
|
||||||
|
|||||||
@@ -17,7 +17,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SubscriptionPanelContent />
|
<!-- Workspace mode: workspace-aware subscription content -->
|
||||||
|
<SubscriptionPanelContentWorkspace v-if="teamWorkspacesEnabled" />
|
||||||
|
<!-- Legacy mode: user-level subscription content -->
|
||||||
|
<SubscriptionPanelContentLegacy v-else />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between border-t border-interface-stroke pt-3"
|
class="flex items-center justify-between border-t border-interface-stroke pt-3"
|
||||||
@@ -65,13 +68,24 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import TabPanel from 'primevue/tabpanel'
|
import TabPanel from 'primevue/tabpanel'
|
||||||
|
import { defineAsyncComponent } from 'vue'
|
||||||
|
|
||||||
import CloudBadge from '@/components/topbar/CloudBadge.vue'
|
import CloudBadge from '@/components/topbar/CloudBadge.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { useExternalLink } from '@/composables/useExternalLink'
|
import { useExternalLink } from '@/composables/useExternalLink'
|
||||||
import SubscriptionPanelContent from '@/platform/cloud/subscription/components/SubscriptionPanelContent.vue'
|
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||||
|
import SubscriptionPanelContentLegacy from '@/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue'
|
||||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||||
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
|
|
||||||
|
const SubscriptionPanelContentWorkspace = defineAsyncComponent(
|
||||||
|
() =>
|
||||||
|
import('@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue')
|
||||||
|
)
|
||||||
|
|
||||||
|
const { flags } = useFeatureFlags()
|
||||||
|
const teamWorkspacesEnabled = isCloud && flags.teamWorkspacesEnabled
|
||||||
|
|
||||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
<template>
|
||||||
|
<div class="grow overflow-auto">
|
||||||
|
<div class="rounded-2xl border border-interface-stroke p-6">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="text-sm font-bold text-text-primary">
|
||||||
|
{{ subscriptionTierName }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-baseline gap-1 font-inter font-semibold">
|
||||||
|
<span class="text-2xl">${{ tierPrice }}</span>
|
||||||
|
<span class="text-base">{{ $t('subscription.perMonth') }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="isActiveSubscription"
|
||||||
|
class="text-sm text-text-secondary"
|
||||||
|
>
|
||||||
|
<template v-if="isCancelled">
|
||||||
|
{{
|
||||||
|
$t('subscription.expiresDate', {
|
||||||
|
date: formattedEndDate
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{
|
||||||
|
$t('subscription.renewsDate', {
|
||||||
|
date: formattedRenewalDate
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
v-if="isActiveSubscription"
|
||||||
|
variant="secondary"
|
||||||
|
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
|
||||||
|
@click="
|
||||||
|
async () => {
|
||||||
|
await authActions.accessBillingPortal()
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ $t('subscription.manageSubscription') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="isActiveSubscription"
|
||||||
|
variant="primary"
|
||||||
|
class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary"
|
||||||
|
@click="showSubscriptionDialog"
|
||||||
|
>
|
||||||
|
{{ $t('subscription.upgradePlan') }}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<SubscribeButton
|
||||||
|
v-if="!isActiveSubscription"
|
||||||
|
:label="$t('subscription.subscribeNow')"
|
||||||
|
size="sm"
|
||||||
|
:fluid="false"
|
||||||
|
class="text-xs"
|
||||||
|
@subscribed="handleRefresh"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col lg:flex-row gap-6 pt-9">
|
||||||
|
<div class="flex flex-col shrink-0">
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'relative flex flex-col gap-6 rounded-2xl p-5',
|
||||||
|
'bg-modal-panel-background'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="muted-textonly"
|
||||||
|
size="icon-sm"
|
||||||
|
class="absolute top-4 right-4"
|
||||||
|
:loading="isLoadingBalance"
|
||||||
|
@click="handleRefresh"
|
||||||
|
>
|
||||||
|
<i class="pi pi-sync text-text-secondary text-sm" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="text-sm text-muted">
|
||||||
|
{{ $t('subscription.totalCredits') }}
|
||||||
|
</div>
|
||||||
|
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
|
||||||
|
<div v-else class="text-2xl font-bold">
|
||||||
|
{{ totalCredits }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Credit Breakdown -->
|
||||||
|
<table class="text-sm text-muted">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="pr-4 font-bold text-left align-middle">
|
||||||
|
<Skeleton
|
||||||
|
v-if="isLoadingBalance"
|
||||||
|
width="5rem"
|
||||||
|
height="1rem"
|
||||||
|
/>
|
||||||
|
<span v-else>{{ includedCreditsDisplay }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="align-middle" :title="creditsRemainingLabel">
|
||||||
|
{{ creditsRemainingLabel }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="pr-4 font-bold text-left align-middle">
|
||||||
|
<Skeleton
|
||||||
|
v-if="isLoadingBalance"
|
||||||
|
width="3rem"
|
||||||
|
height="1rem"
|
||||||
|
/>
|
||||||
|
<span v-else>{{ prepaidCredits }}</span>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="align-middle"
|
||||||
|
:title="$t('subscription.creditsYouveAdded')"
|
||||||
|
>
|
||||||
|
{{ $t('subscription.creditsYouveAdded') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<a
|
||||||
|
href="https://platform.comfy.org/profile/usage"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-sm underline text-center text-muted"
|
||||||
|
>
|
||||||
|
{{ $t('subscription.viewUsageHistory') }}
|
||||||
|
</a>
|
||||||
|
<Button
|
||||||
|
v-if="isActiveSubscription"
|
||||||
|
variant="secondary"
|
||||||
|
class="p-2 min-h-8 rounded-lg text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
|
||||||
|
@click="handleAddApiCredits"
|
||||||
|
>
|
||||||
|
{{ $t('subscription.addCredits') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="text-sm text-text-primary">
|
||||||
|
{{ $t('subscription.yourPlanIncludes') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-0">
|
||||||
|
<div
|
||||||
|
v-for="benefit in tierBenefits"
|
||||||
|
:key="benefit.key"
|
||||||
|
class="flex items-center gap-2 py-2"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
v-if="benefit.type === 'feature'"
|
||||||
|
class="pi pi-check text-xs text-text-primary"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else-if="benefit.type === 'metric' && benefit.value"
|
||||||
|
class="text-sm font-normal whitespace-nowrap text-text-primary"
|
||||||
|
>
|
||||||
|
{{ benefit.value }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-muted">
|
||||||
|
{{ benefit.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View More Details - Outside main content -->
|
||||||
|
<div class="flex items-center gap-2 py-4">
|
||||||
|
<i class="pi pi-external-link text-muted"></i>
|
||||||
|
<a
|
||||||
|
href="https://www.comfy.org/cloud/pricing"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-sm underline hover:opacity-80 text-muted"
|
||||||
|
>
|
||||||
|
{{ $t('subscription.viewMoreDetailsPlans') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Skeleton from 'primevue/skeleton'
|
||||||
|
import { computed, onBeforeUnmount, onMounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
|
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||||
|
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
||||||
|
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||||
|
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||||
|
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||||
|
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||||
|
import {
|
||||||
|
DEFAULT_TIER_KEY,
|
||||||
|
TIER_TO_KEY,
|
||||||
|
getTierCredits,
|
||||||
|
getTierFeatures,
|
||||||
|
getTierPrice
|
||||||
|
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||||
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
const authActions = useFirebaseAuthActions()
|
||||||
|
const { t, n } = useI18n()
|
||||||
|
|
||||||
|
const {
|
||||||
|
isActiveSubscription,
|
||||||
|
isCancelled,
|
||||||
|
formattedRenewalDate,
|
||||||
|
formattedEndDate,
|
||||||
|
subscriptionTier,
|
||||||
|
subscriptionTierName,
|
||||||
|
subscriptionStatus,
|
||||||
|
isYearlySubscription
|
||||||
|
} = useSubscription()
|
||||||
|
|
||||||
|
const { show: showSubscriptionDialog } = useSubscriptionDialog()
|
||||||
|
|
||||||
|
const tierKey = computed(() => {
|
||||||
|
const tier = subscriptionTier.value
|
||||||
|
if (!tier) return DEFAULT_TIER_KEY
|
||||||
|
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
|
||||||
|
})
|
||||||
|
const tierPrice = computed(() =>
|
||||||
|
getTierPrice(tierKey.value, isYearlySubscription.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
const refillsDate = computed(() => {
|
||||||
|
if (!subscriptionStatus.value?.renewal_date) return ''
|
||||||
|
const date = new Date(subscriptionStatus.value.renewal_date)
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const year = String(date.getFullYear()).slice(-2)
|
||||||
|
return `${month}/${day}/${year}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const creditsRemainingLabel = computed(() =>
|
||||||
|
isYearlySubscription.value
|
||||||
|
? t('subscription.creditsRemainingThisYear', {
|
||||||
|
date: refillsDate.value
|
||||||
|
})
|
||||||
|
: t('subscription.creditsRemainingThisMonth', {
|
||||||
|
date: refillsDate.value
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const planTotalCredits = computed(() => {
|
||||||
|
const credits = getTierCredits(tierKey.value)
|
||||||
|
const total = isYearlySubscription.value ? credits * 12 : credits
|
||||||
|
return n(total)
|
||||||
|
})
|
||||||
|
|
||||||
|
const includedCreditsDisplay = computed(
|
||||||
|
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tier benefits for v-for loop
|
||||||
|
type BenefitType = 'metric' | 'feature'
|
||||||
|
|
||||||
|
interface Benefit {
|
||||||
|
key: string
|
||||||
|
type: BenefitType
|
||||||
|
label: string
|
||||||
|
value?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const tierBenefits = computed((): Benefit[] => {
|
||||||
|
const key = tierKey.value
|
||||||
|
|
||||||
|
const benefits: Benefit[] = [
|
||||||
|
{
|
||||||
|
key: 'maxDuration',
|
||||||
|
type: 'metric',
|
||||||
|
value: t(`subscription.maxDuration.${key}`),
|
||||||
|
label: t('subscription.maxDurationLabel')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gpu',
|
||||||
|
type: 'feature',
|
||||||
|
label: t('subscription.gpuLabel')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'addCredits',
|
||||||
|
type: 'feature',
|
||||||
|
label: t('subscription.addCreditsLabel')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
if (getTierFeatures(key).customLoRAs) {
|
||||||
|
benefits.push({
|
||||||
|
key: 'customLoRAs',
|
||||||
|
type: 'feature',
|
||||||
|
label: t('subscription.customLoRAsLabel')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return benefits
|
||||||
|
})
|
||||||
|
|
||||||
|
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
|
||||||
|
useSubscriptionCredits()
|
||||||
|
|
||||||
|
const { handleAddApiCredits, handleRefresh } = useSubscriptionActions()
|
||||||
|
|
||||||
|
// Focus-based polling: refresh balance when user returns from Stripe checkout
|
||||||
|
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
|
||||||
|
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes
|
||||||
|
|
||||||
|
function handleWindowFocus() {
|
||||||
|
const timestampStr = localStorage.getItem(PENDING_TOPUP_KEY)
|
||||||
|
if (!timestampStr) return
|
||||||
|
|
||||||
|
const timestamp = parseInt(timestampStr, 10)
|
||||||
|
|
||||||
|
// Clear expired tracking (older than 5 minutes)
|
||||||
|
if (Date.now() - timestamp > TOPUP_EXPIRY_MS) {
|
||||||
|
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh and clear tracking to prevent repeated calls
|
||||||
|
void handleRefresh()
|
||||||
|
localStorage.removeItem(PENDING_TOPUP_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('focus', handleWindowFocus)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('focus', handleWindowFocus)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.bg-comfy-menu-secondary) {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -22,7 +22,19 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Normal Subscribed State -->
|
<!-- MEMBER View - read-only, no subscription data yet -->
|
||||||
|
<template v-else-if="isMemberView">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="text-sm font-bold text-text-primary">
|
||||||
|
{{ $t('subscription.workspaceNotSubscribed') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-text-secondary">
|
||||||
|
{{ $t('subscription.contactOwnerToSubscribe') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Normal Subscribed State (Owner with subscription) -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="text-sm font-bold text-text-primary">
|
<div class="text-sm font-bold text-text-primary">
|
||||||
@@ -85,15 +97,6 @@
|
|||||||
</Button>
|
</Button>
|
||||||
<Menu ref="planMenu" :model="planMenuItems" :popup="true" />
|
<Menu ref="planMenu" :model="planMenuItems" :popup="true" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<SubscribeButton
|
|
||||||
v-if="!isActiveSubscription"
|
|
||||||
:label="$t('subscription.subscribeNow')"
|
|
||||||
size="sm"
|
|
||||||
:fluid="false"
|
|
||||||
class="text-xs"
|
|
||||||
@subscribed="handleRefresh"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,7 +128,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
|
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
|
||||||
<div v-else class="text-2xl font-bold">
|
<div v-else class="text-2xl font-bold">
|
||||||
{{ isOwnerUnsubscribed ? '0' : totalCredits }}
|
{{ showZeroState ? '0' : totalCredits }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -140,7 +143,7 @@
|
|||||||
height="1rem"
|
height="1rem"
|
||||||
/>
|
/>
|
||||||
<span v-else>{{
|
<span v-else>{{
|
||||||
isOwnerUnsubscribed ? '0 / 0' : includedCreditsDisplay
|
showZeroState ? '0 / 0' : includedCreditsDisplay
|
||||||
}}</span>
|
}}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="align-middle" :title="creditsRemainingLabel">
|
<td class="align-middle" :title="creditsRemainingLabel">
|
||||||
@@ -155,7 +158,7 @@
|
|||||||
height="1rem"
|
height="1rem"
|
||||||
/>
|
/>
|
||||||
<span v-else>{{
|
<span v-else>{{
|
||||||
isOwnerUnsubscribed ? '0' : prepaidCredits
|
showZeroState ? '0' : prepaidCredits
|
||||||
}}</span>
|
}}</span>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
@@ -178,7 +181,7 @@
|
|||||||
{{ $t('subscription.viewUsageHistory') }}
|
{{ $t('subscription.viewUsageHistory') }}
|
||||||
</a>
|
</a>
|
||||||
<Button
|
<Button
|
||||||
v-if="isActiveSubscription && !isOwnerUnsubscribed"
|
v-if="isActiveSubscription && !showZeroState"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="p-2 min-h-8 rounded-lg text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
|
class="p-2 min-h-8 rounded-lg text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
|
||||||
@click="handleAddApiCredits"
|
@click="handleAddApiCredits"
|
||||||
@@ -244,7 +247,6 @@ import { useI18n } from 'vue-i18n'
|
|||||||
|
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||||
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
|
|
||||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||||
@@ -267,11 +269,19 @@ const { subscribeWorkspace } = workspaceStore
|
|||||||
const { permissions, workspaceRole } = useWorkspaceUI()
|
const { permissions, workspaceRole } = useWorkspaceUI()
|
||||||
const { t, n } = useI18n()
|
const { t, n } = useI18n()
|
||||||
|
|
||||||
// OWNER with unsubscribed workspace
|
// OWNER with unsubscribed workspace - can see subscribe button
|
||||||
const isOwnerUnsubscribed = computed(
|
const isOwnerUnsubscribed = computed(
|
||||||
() => workspaceRole.value === 'owner' && !isWorkspaceSubscribed.value
|
() => workspaceRole.value === 'owner' && !isWorkspaceSubscribed.value
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MEMBER view - members can't manage subscription, show read-only zero state
|
||||||
|
const isMemberView = computed(() => !permissions.value.canManageSubscription)
|
||||||
|
|
||||||
|
// Show zero state for credits (no real billing data yet)
|
||||||
|
const showZeroState = computed(
|
||||||
|
() => isOwnerUnsubscribed.value || isMemberView.value
|
||||||
|
)
|
||||||
|
|
||||||
// Demo: Subscribe workspace to PRO monthly plan
|
// Demo: Subscribe workspace to PRO monthly plan
|
||||||
function handleSubscribeWorkspace() {
|
function handleSubscribeWorkspace() {
|
||||||
subscribeWorkspace('PRO_MONTHLY')
|
subscribeWorkspace('PRO_MONTHLY')
|
||||||
@@ -1,6 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-[80vh] w-full overflow-hidden">
|
<div
|
||||||
<ScrollPanel class="w-48 shrink-0 p-2 2xl:w-64">
|
:class="
|
||||||
|
teamWorkspacesEnabled
|
||||||
|
? 'flex h-[80vh] w-full overflow-hidden'
|
||||||
|
: 'settings-container'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<ScrollPanel
|
||||||
|
:class="
|
||||||
|
teamWorkspacesEnabled
|
||||||
|
? 'w-48 shrink-0 p-2 2xl:w-64'
|
||||||
|
: 'settings-sidebar w-48 shrink-0 p-2 2xl:w-64'
|
||||||
|
"
|
||||||
|
>
|
||||||
<SearchBox
|
<SearchBox
|
||||||
v-model:model-value="searchQuery"
|
v-model:model-value="searchQuery"
|
||||||
class="settings-search-box mb-2 w-full"
|
class="settings-search-box mb-2 w-full"
|
||||||
@@ -20,15 +32,24 @@
|
|||||||
(option: SettingTreeNode) =>
|
(option: SettingTreeNode) =>
|
||||||
!queryIsEmpty && !searchResultsCategories.has(option.label ?? '')
|
!queryIsEmpty && !searchResultsCategories.has(option.label ?? '')
|
||||||
"
|
"
|
||||||
class="w-full border-none bg-transparent"
|
:class="
|
||||||
|
teamWorkspacesEnabled
|
||||||
|
? 'w-full border-none bg-transparent'
|
||||||
|
: 'w-full border-none'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<template #optiongroup="{ option }">
|
<!-- Workspace mode: custom group headers -->
|
||||||
<!-- <Divider v-if="option.key !== 'workspace'" class="my-2" /> -->
|
<template v-if="teamWorkspacesEnabled" #optiongroup="{ option }">
|
||||||
<h3 class="text-xs font-semibold uppercase text-muted m-0 pt-6 pb-2">
|
<h3 class="text-xs font-semibold uppercase text-muted m-0 pt-6 pb-2">
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</h3>
|
</h3>
|
||||||
</template>
|
</template>
|
||||||
<template #option="{ option }">
|
<!-- Legacy mode: divider between groups -->
|
||||||
|
<template v-else #optiongroup>
|
||||||
|
<Divider class="my-0" />
|
||||||
|
</template>
|
||||||
|
<!-- Workspace mode: custom workspace item -->
|
||||||
|
<template v-if="teamWorkspacesEnabled" #option="{ option }">
|
||||||
<WorkspaceSidebarItem v-if="option.key === 'workspace'" />
|
<WorkspaceSidebarItem v-if="option.key === 'workspace'" />
|
||||||
<span v-else>{{ option.translatedLabel }}</span>
|
<span v-else>{{ option.translatedLabel }}</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -36,7 +57,15 @@
|
|||||||
</ScrollPanel>
|
</ScrollPanel>
|
||||||
<Divider layout="vertical" class="mx-1 hidden md:flex 2xl:mx-4" />
|
<Divider layout="vertical" class="mx-1 hidden md:flex 2xl:mx-4" />
|
||||||
<Divider layout="horizontal" class="flex md:hidden" />
|
<Divider layout="horizontal" class="flex md:hidden" />
|
||||||
<Tabs :value="tabValue" :lazy="true" class="h-full flex-1 overflow-x-auto">
|
<Tabs
|
||||||
|
:value="tabValue"
|
||||||
|
:lazy="true"
|
||||||
|
:class="
|
||||||
|
teamWorkspacesEnabled
|
||||||
|
? 'h-full flex-1 overflow-x-auto'
|
||||||
|
: 'settings-content h-full w-full'
|
||||||
|
"
|
||||||
|
>
|
||||||
<TabPanels class="settings-tab-panels h-full w-full pr-0">
|
<TabPanels class="settings-tab-panels h-full w-full pr-0">
|
||||||
<PanelTemplate value="Search Results">
|
<PanelTemplate value="Search Results">
|
||||||
<SettingsPanel :setting-groups="searchResults" />
|
<SettingsPanel :setting-groups="searchResults" />
|
||||||
@@ -78,6 +107,8 @@ import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserM
|
|||||||
import PanelTemplate from '@/components/dialog/content/setting/PanelTemplate.vue'
|
import PanelTemplate from '@/components/dialog/content/setting/PanelTemplate.vue'
|
||||||
import WorkspaceSidebarItem from '@/components/dialog/content/setting/WorkspaceSidebarItem.vue'
|
import WorkspaceSidebarItem from '@/components/dialog/content/setting/WorkspaceSidebarItem.vue'
|
||||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||||
|
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||||
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
|
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
|
||||||
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
|
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
|
||||||
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
|
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
|
||||||
@@ -98,6 +129,9 @@ const { defaultPanel } = defineProps<{
|
|||||||
| 'workspace'
|
| 'workspace'
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { flags } = useFeatureFlags()
|
||||||
|
const teamWorkspacesEnabled = isCloud && flags.teamWorkspacesEnabled
|
||||||
|
|
||||||
const {
|
const {
|
||||||
activeCategory,
|
activeCategory,
|
||||||
defaultCategory,
|
defaultCategory,
|
||||||
@@ -170,3 +204,39 @@ watch(activeCategory, (_, oldValue) => {
|
|||||||
padding-top: 0 !important;
|
padding-top: 0 !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Legacy mode styles (when teamWorkspacesEnabled is false) */
|
||||||
|
.settings-container {
|
||||||
|
display: flex;
|
||||||
|
height: 70vh;
|
||||||
|
width: 60vw;
|
||||||
|
max-width: 64rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-content {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.settings-container {
|
||||||
|
flex-direction: column;
|
||||||
|
height: auto;
|
||||||
|
width: 80vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-content {
|
||||||
|
height: 350px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide the first group separator in legacy mode */
|
||||||
|
.settings-sidebar :deep(.p-listbox-option-group:nth-child(1)) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -3,15 +3,16 @@ import type { Component } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||||
|
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||||
|
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import type { SettingParams } from '@/platform/settings/types'
|
import type { SettingParams } from '@/platform/settings/types'
|
||||||
|
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||||
import { isElectron } from '@/utils/envUtil'
|
import { isElectron } from '@/utils/envUtil'
|
||||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||||
import { buildTree } from '@/utils/treeUtil'
|
import { buildTree } from '@/utils/treeUtil'
|
||||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
|
||||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
|
||||||
|
|
||||||
interface SettingPanelItem {
|
interface SettingPanelItem {
|
||||||
node: SettingTreeNode
|
node: SettingTreeNode
|
||||||
@@ -35,9 +36,12 @@ export function useSettingUI(
|
|||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const activeCategory = ref<SettingTreeNode | null>(null)
|
const activeCategory = ref<SettingTreeNode | null>(null)
|
||||||
|
|
||||||
|
const { flags } = useFeatureFlags()
|
||||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||||
const { isActiveSubscription } = useSubscription()
|
const { isActiveSubscription } = useSubscription()
|
||||||
|
|
||||||
|
const teamWorkspacesEnabled = isCloud && flags.teamWorkspacesEnabled
|
||||||
|
|
||||||
const settingRoot = computed<SettingTreeNode>(() => {
|
const settingRoot = computed<SettingTreeNode>(() => {
|
||||||
const root = buildTree(
|
const root = buildTree(
|
||||||
Object.values(settingStore.settingsById).filter(
|
Object.values(settingStore.settingsById).filter(
|
||||||
@@ -139,7 +143,7 @@ export function useSettingUI(
|
|||||||
const userPanel: SettingPanelItem = {
|
const userPanel: SettingPanelItem = {
|
||||||
node: {
|
node: {
|
||||||
key: 'user',
|
key: 'user',
|
||||||
label: 'Profile',
|
label: 'User',
|
||||||
children: []
|
children: []
|
||||||
},
|
},
|
||||||
component: defineAsyncComponent(
|
component: defineAsyncComponent(
|
||||||
@@ -147,16 +151,24 @@ export function useSettingUI(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspacePanel: SettingPanelItem = {
|
// Workspace panel: only available on cloud with team workspaces enabled
|
||||||
node: {
|
const workspacePanel: SettingPanelItem | null = !teamWorkspacesEnabled
|
||||||
key: 'workspace',
|
? null
|
||||||
label: 'Workspace',
|
: {
|
||||||
children: []
|
node: {
|
||||||
},
|
key: 'workspace',
|
||||||
component: defineAsyncComponent(
|
label: 'Workspace',
|
||||||
() => import('@/components/dialog/content/setting/WorkspacePanel.vue')
|
children: []
|
||||||
)
|
},
|
||||||
}
|
component: defineAsyncComponent(
|
||||||
|
() => import('@/components/dialog/content/setting/WorkspacePanel.vue')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldShowWorkspacePanel = computed(() => {
|
||||||
|
if (!workspacePanel) return false
|
||||||
|
return isLoggedIn.value
|
||||||
|
})
|
||||||
|
|
||||||
const keybindingPanel: SettingPanelItem = {
|
const keybindingPanel: SettingPanelItem = {
|
||||||
node: {
|
node: {
|
||||||
@@ -196,14 +208,16 @@ export function useSettingUI(
|
|||||||
aboutPanel,
|
aboutPanel,
|
||||||
creditsPanel,
|
creditsPanel,
|
||||||
userPanel,
|
userPanel,
|
||||||
workspacePanel,
|
...(shouldShowWorkspacePanel.value && workspacePanel
|
||||||
|
? [workspacePanel]
|
||||||
|
: []),
|
||||||
keybindingPanel,
|
keybindingPanel,
|
||||||
extensionPanel,
|
extensionPanel,
|
||||||
...(isElectron() ? [serverConfigPanel] : []),
|
...(isElectron() ? [serverConfigPanel] : []),
|
||||||
...(shouldShowPlanCreditsPanel.value && subscriptionPanel
|
...(shouldShowPlanCreditsPanel.value && subscriptionPanel
|
||||||
? [subscriptionPanel]
|
? [subscriptionPanel]
|
||||||
: [])
|
: [])
|
||||||
].filter((panel) => panel.component)
|
].filter((panel) => panel !== null && panel.component)
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -227,13 +241,16 @@ export function useSettingUI(
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const groupedMenuTreeNodes = computed<SettingTreeNode[]>(() => [
|
// Sidebar structure when team workspaces is enabled
|
||||||
|
const workspaceMenuTreeNodes = computed<SettingTreeNode[]>(() => [
|
||||||
// Workspace settings
|
// Workspace settings
|
||||||
{
|
{
|
||||||
key: 'workspace',
|
key: 'workspace',
|
||||||
label: 'Workspace',
|
label: 'Workspace',
|
||||||
children: [
|
children: [
|
||||||
workspacePanel.node,
|
...(shouldShowWorkspacePanel.value && workspacePanel
|
||||||
|
? [workspacePanel.node]
|
||||||
|
: []),
|
||||||
...(isLoggedIn.value &&
|
...(isLoggedIn.value &&
|
||||||
!(isCloud && window.__CONFIG__?.subscription_required)
|
!(isCloud && window.__CONFIG__?.subscription_required)
|
||||||
? [creditsPanel.node]
|
? [creditsPanel.node]
|
||||||
@@ -265,6 +282,50 @@ export function useSettingUI(
|
|||||||
: [])
|
: [])
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// Sidebar structure when team workspaces is disabled (legacy)
|
||||||
|
const legacyMenuTreeNodes = computed<SettingTreeNode[]>(() => [
|
||||||
|
// Account settings - show different panels based on distribution and auth state
|
||||||
|
{
|
||||||
|
key: 'account',
|
||||||
|
label: 'Account',
|
||||||
|
children: [
|
||||||
|
userPanel.node,
|
||||||
|
...(isLoggedIn.value &&
|
||||||
|
shouldShowPlanCreditsPanel.value &&
|
||||||
|
subscriptionPanel
|
||||||
|
? [subscriptionPanel.node]
|
||||||
|
: []),
|
||||||
|
...(isLoggedIn.value &&
|
||||||
|
!(isCloud && window.__CONFIG__?.subscription_required)
|
||||||
|
? [creditsPanel.node]
|
||||||
|
: [])
|
||||||
|
].map(translateCategory)
|
||||||
|
},
|
||||||
|
// Normal settings stored in the settingStore
|
||||||
|
{
|
||||||
|
key: 'settings',
|
||||||
|
label: 'Application Settings',
|
||||||
|
children: settingCategories.value.map(translateCategory)
|
||||||
|
},
|
||||||
|
// Special settings such as about, keybinding, extension, server-config
|
||||||
|
{
|
||||||
|
key: 'specialSettings',
|
||||||
|
label: 'Special Settings',
|
||||||
|
children: [
|
||||||
|
keybindingPanel.node,
|
||||||
|
extensionPanel.node,
|
||||||
|
aboutPanel.node,
|
||||||
|
...(isElectron() ? [serverConfigPanel.node] : [])
|
||||||
|
].map(translateCategory)
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const groupedMenuTreeNodes = computed<SettingTreeNode[]>(() =>
|
||||||
|
teamWorkspacesEnabled
|
||||||
|
? workspaceMenuTreeNodes.value
|
||||||
|
: legacyMenuTreeNodes.value
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
activeCategory.value = defaultCategory.value
|
activeCategory.value = defaultCategory.value
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ vi.mock('primevue/usetoast', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('vue-i18n', () => ({
|
vi.mock('vue-i18n', () => ({
|
||||||
|
createI18n: () => ({
|
||||||
|
global: {
|
||||||
|
t: (key: string) => key
|
||||||
|
}
|
||||||
|
}),
|
||||||
useI18n: () => ({
|
useI18n: () => ({
|
||||||
t: vi.fn((key: string, params?: Record<string, unknown>) => {
|
t: vi.fn((key: string, params?: Record<string, unknown>) => {
|
||||||
if (key === 'workspace.inviteAccepted') return 'Invite Accepted'
|
if (key === 'workspace.inviteAccepted') return 'Invite Accepted'
|
||||||
@@ -60,8 +65,8 @@ vi.mock('vue-i18n', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const mockAcceptInvite = vi.hoisted(() => vi.fn())
|
const mockAcceptInvite = vi.hoisted(() => vi.fn())
|
||||||
vi.mock('../stores/workspaceStore', () => ({
|
vi.mock('../stores/teamWorkspaceStore', () => ({
|
||||||
useWorkspaceStore: () => ({
|
useTeamWorkspaceStore: () => ({
|
||||||
acceptInvite: mockAcceptInvite
|
acceptInvite: mockAcceptInvite
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
|||||||
1069
src/platform/workspace/stores/teamWorkspaceStore.test.ts
Normal file
1069
src/platform/workspace/stores/teamWorkspaceStore.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -702,10 +702,7 @@ export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
|
|||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function subscribeWorkspace(plan: SubscriptionPlan = 'PRO_MONTHLY') {
|
function subscribeWorkspace(plan: SubscriptionPlan = 'PRO_MONTHLY') {
|
||||||
updateActiveWorkspace({
|
console.warn(plan, 'Billing endpoint has not been added yet.')
|
||||||
isSubscribed: true,
|
|
||||||
subscriptionPlan: plan
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -2,13 +2,6 @@ import { merge } from 'es-toolkit/compat'
|
|||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
|
|
||||||
import ApiNodesSignInContent from '@/components/dialog/content/ApiNodesSignInContent.vue'
|
import ApiNodesSignInContent from '@/components/dialog/content/ApiNodesSignInContent.vue'
|
||||||
import CreateWorkspaceDialogContent from '@/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue'
|
|
||||||
import EditWorkspaceDialogContent from '@/components/dialog/content/workspace/EditWorkspaceDialogContent.vue'
|
|
||||||
import DeleteWorkspaceDialogContent from '@/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue'
|
|
||||||
import InviteMemberDialogContent from '@/components/dialog/content/workspace/InviteMemberDialogContent.vue'
|
|
||||||
import LeaveWorkspaceDialogContent from '@/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue'
|
|
||||||
import RemoveMemberDialogContent from '@/components/dialog/content/workspace/RemoveMemberDialogContent.vue'
|
|
||||||
import RevokeInviteDialogContent from '@/components/dialog/content/workspace/RevokeInviteDialogContent.vue'
|
|
||||||
import MissingNodesContent from '@/components/dialog/content/MissingNodesContent.vue'
|
import MissingNodesContent from '@/components/dialog/content/MissingNodesContent.vue'
|
||||||
import MissingNodesFooter from '@/components/dialog/content/MissingNodesFooter.vue'
|
import MissingNodesFooter from '@/components/dialog/content/MissingNodesFooter.vue'
|
||||||
import MissingNodesHeader from '@/components/dialog/content/MissingNodesHeader.vue'
|
import MissingNodesHeader from '@/components/dialog/content/MissingNodesHeader.vue'
|
||||||
@@ -527,117 +520,110 @@ export const useDialogService = () => {
|
|||||||
show()
|
show()
|
||||||
}
|
}
|
||||||
|
|
||||||
function showLeaveWorkspaceDialog() {
|
// Workspace dialogs - dynamically imported to avoid bundling when feature flag is off
|
||||||
|
const workspaceDialogPt = {
|
||||||
|
headless: true,
|
||||||
|
pt: {
|
||||||
|
header: { class: 'p-0! hidden' },
|
||||||
|
content: { class: 'p-0! m-0! rounded-2xl' },
|
||||||
|
root: { class: 'rounded-2xl' }
|
||||||
|
}
|
||||||
|
} as const
|
||||||
|
|
||||||
|
async function showLeaveWorkspaceDialog() {
|
||||||
|
const { default: component } =
|
||||||
|
await import('@/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue')
|
||||||
return dialogStore.showDialog({
|
return dialogStore.showDialog({
|
||||||
key: 'leave-workspace',
|
key: 'leave-workspace',
|
||||||
component: LeaveWorkspaceDialogContent,
|
component,
|
||||||
dialogComponentProps: {
|
dialogComponentProps: workspaceDialogPt
|
||||||
headless: true,
|
|
||||||
pt: {
|
|
||||||
header: { class: 'p-0! hidden' },
|
|
||||||
content: { class: 'p-0! m-0! rounded-2xl' },
|
|
||||||
root: { class: 'rounded-2xl' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function showDeleteWorkspaceDialog(options?: {
|
async function showDeleteWorkspaceDialog(options?: {
|
||||||
workspaceId?: string
|
workspaceId?: string
|
||||||
workspaceName?: string
|
workspaceName?: string
|
||||||
}) {
|
}) {
|
||||||
|
const { default: component } =
|
||||||
|
await import('@/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue')
|
||||||
return dialogStore.showDialog({
|
return dialogStore.showDialog({
|
||||||
key: 'delete-workspace',
|
key: 'delete-workspace',
|
||||||
component: DeleteWorkspaceDialogContent,
|
component,
|
||||||
props: options,
|
props: options,
|
||||||
dialogComponentProps: {
|
dialogComponentProps: workspaceDialogPt
|
||||||
headless: true,
|
|
||||||
pt: {
|
|
||||||
header: { class: 'p-0! hidden' },
|
|
||||||
content: { class: 'p-0! m-0! rounded-2xl' },
|
|
||||||
root: { class: 'rounded-2xl' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function showRemoveMemberDialog(memberId: string) {
|
async function showRemoveMemberDialog(memberId: string) {
|
||||||
|
const { default: component } =
|
||||||
|
await import('@/components/dialog/content/workspace/RemoveMemberDialogContent.vue')
|
||||||
return dialogStore.showDialog({
|
return dialogStore.showDialog({
|
||||||
key: 'remove-member',
|
key: 'remove-member',
|
||||||
component: RemoveMemberDialogContent,
|
component,
|
||||||
props: { memberId },
|
props: { memberId },
|
||||||
dialogComponentProps: {
|
dialogComponentProps: workspaceDialogPt
|
||||||
headless: true,
|
|
||||||
pt: {
|
|
||||||
header: { class: 'p-0! hidden' },
|
|
||||||
content: { class: 'p-0! m-0! rounded-2xl' },
|
|
||||||
root: { class: 'rounded-2xl' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function showRevokeInviteDialog(inviteId: string) {
|
async function showRevokeInviteDialog(inviteId: string) {
|
||||||
|
const { default: component } =
|
||||||
|
await import('@/components/dialog/content/workspace/RevokeInviteDialogContent.vue')
|
||||||
return dialogStore.showDialog({
|
return dialogStore.showDialog({
|
||||||
key: 'revoke-invite',
|
key: 'revoke-invite',
|
||||||
component: RevokeInviteDialogContent,
|
component,
|
||||||
props: { inviteId },
|
props: { inviteId },
|
||||||
dialogComponentProps: {
|
dialogComponentProps: workspaceDialogPt
|
||||||
headless: true,
|
|
||||||
pt: {
|
|
||||||
header: { class: 'p-0! hidden' },
|
|
||||||
content: { class: 'p-0! m-0! rounded-2xl' },
|
|
||||||
root: { class: 'rounded-2xl' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function showInviteMemberDialog(
|
async function showInviteMemberDialog(
|
||||||
onConfirm: (email: string) => void | Promise<void>
|
onConfirm: (email: string) => void | Promise<void>
|
||||||
) {
|
) {
|
||||||
|
const { default: component } =
|
||||||
|
await import('@/components/dialog/content/workspace/InviteMemberDialogContent.vue')
|
||||||
return dialogStore.showDialog({
|
return dialogStore.showDialog({
|
||||||
key: 'invite-member',
|
key: 'invite-member',
|
||||||
component: InviteMemberDialogContent,
|
component,
|
||||||
props: { onConfirm },
|
props: { onConfirm },
|
||||||
dialogComponentProps: {
|
dialogComponentProps: {
|
||||||
headless: true,
|
...workspaceDialogPt,
|
||||||
pt: {
|
pt: {
|
||||||
header: { class: 'p-0! hidden' },
|
...workspaceDialogPt.pt,
|
||||||
content: { class: 'p-0! m-0! rounded-2xl' },
|
|
||||||
root: { class: 'rounded-2xl max-w-[512px] w-full' }
|
root: { class: 'rounded-2xl max-w-[512px] w-full' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function showCreateWorkspaceDialog(
|
async function showCreateWorkspaceDialog(
|
||||||
onConfirm?: (name: string) => void | Promise<void>
|
onConfirm?: (name: string) => void | Promise<void>
|
||||||
) {
|
) {
|
||||||
|
const { default: component } =
|
||||||
|
await import('@/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue')
|
||||||
return dialogStore.showDialog({
|
return dialogStore.showDialog({
|
||||||
key: 'create-workspace',
|
key: 'create-workspace',
|
||||||
component: CreateWorkspaceDialogContent,
|
component,
|
||||||
props: { onConfirm },
|
props: { onConfirm },
|
||||||
dialogComponentProps: {
|
dialogComponentProps: {
|
||||||
headless: true,
|
...workspaceDialogPt,
|
||||||
pt: {
|
pt: {
|
||||||
header: { class: 'p-0! hidden' },
|
...workspaceDialogPt.pt,
|
||||||
content: { class: 'p-0! m-0! rounded-2xl' },
|
|
||||||
root: { class: 'rounded-2xl max-w-[400px] w-full' }
|
root: { class: 'rounded-2xl max-w-[400px] w-full' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function showEditWorkspaceDialog() {
|
async function showEditWorkspaceDialog() {
|
||||||
|
const { default: component } =
|
||||||
|
await import('@/components/dialog/content/workspace/EditWorkspaceDialogContent.vue')
|
||||||
return dialogStore.showDialog({
|
return dialogStore.showDialog({
|
||||||
key: 'edit-workspace',
|
key: 'edit-workspace',
|
||||||
component: EditWorkspaceDialogContent,
|
component,
|
||||||
dialogComponentProps: {
|
dialogComponentProps: {
|
||||||
headless: true,
|
...workspaceDialogPt,
|
||||||
pt: {
|
pt: {
|
||||||
header: { class: 'p-0! hidden' },
|
...workspaceDialogPt.pt,
|
||||||
content: { class: 'p-0! m-0! rounded-2xl' },
|
|
||||||
root: { class: 'rounded-2xl max-w-[400px] w-full' }
|
root: { class: 'rounded-2xl max-w-[400px] w-full' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user