feat(workspace): add UI panels, popovers, and integration layer

Panels & Popovers:
- Add CurrentUserPopoverWorkspace for workspace-aware user menu
- Add WorkspaceSwitcherPopover for switching workspaces
- Add WorkspacePanel, WorkspacePanelContent, WorkspaceSidebarItem
- Add MembersPanelContent for member management
- Add SubscriptionPanelContentWorkspace and SubscriptionPanelContentLegacy
- Add WorkspaceCreatedToast

Integration Layer:
- Update dialogService with workspace dialog methods
- Update useSettingUI for workspace panel visibility
- Update SettingDialogContent for workspace mode layout
- Update SubscriptionPanel to switch between Legacy/Workspace content
- Update GlobalDialog styling for workspace mode
- Add all workspace i18n strings
This commit is contained in:
--list
2026-01-20 12:09:05 -08:00
parent 2d56d39223
commit 78e8b428d8
15 changed files with 2551 additions and 377 deletions

View File

@@ -4,7 +4,12 @@
v-for="item in dialogStore.dialogStack"
:key="item.key"
v-model:visible="item.visible"
class="global-dialog"
:class="[
'global-dialog',
item.key === 'global-settings' && teamWorkspacesEnabled
? 'settings-dialog-workspace'
: ''
]"
v-bind="item.dialogComponentProps"
:pt="item.dialogComponentProps.pt"
:aria-labelledby="item.key"
@@ -38,8 +43,13 @@
<script setup lang="ts">
import Dialog from 'primevue/dialog'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useDialogStore } from '@/stores/dialogStore'
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = isCloud && flags.teamWorkspacesEnabled
const dialogStore = useDialogStore()
</script>
@@ -55,4 +65,27 @@ const dialogStore = useDialogStore()
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
@apply pt-0;
}
/* Workspace mode: wider settings dialog */
.settings-dialog-workspace {
width: 100%;
max-width: 1440px;
}
.settings-dialog-workspace .p-dialog-content {
width: 100%;
}
.manager-dialog {
height: 80vh;
max-width: 1724px;
max-height: 1026px;
}
@media (min-width: 3000px) {
.manager-dialog {
max-width: 2200px;
max-height: 1320px;
}
}
</style>

View File

@@ -0,0 +1,483 @@
<template>
<div
class="flex size-full flex-col gap-2 rounded-2xl border border-border-default p-6"
>
<!-- Section Header -->
<div class="flex w-full items-center gap-9">
<div class="flex min-w-0 flex-1 items-baseline gap-2">
<span
v-if="uiConfig.showMembersList"
class="text-base font-semibold text-base-foreground"
>
<template v-if="activeView === 'active'">
{{
$t('workspacePanel.members.membersCount', {
count: members.length
})
}}
</template>
<template v-else-if="permissions.canViewPendingInvites">
{{
$t(
'workspacePanel.members.pendingInvitesCount',
pendingInvites.length
)
}}
</template>
</span>
</div>
<div v-if="uiConfig.showSearch" class="flex items-start gap-2">
<SearchBox
v-model="searchQuery"
:placeholder="$t('g.search')"
size="lg"
class="w-64"
/>
</div>
</div>
<!-- Members Content -->
<div class="flex min-h-0 flex-1 flex-col">
<!-- Table Header with Tab Buttons and Column Headers -->
<div
v-if="uiConfig.showMembersList"
:class="
cn(
'grid w-full items-center py-2',
activeView === 'pending'
? uiConfig.pendingGridCols
: uiConfig.headerGridCols
)
"
>
<!-- Tab buttons in first column -->
<div class="flex items-center gap-2">
<Button
:variant="activeView === 'active' ? 'secondary' : 'muted-textonly'"
size="md"
@click="activeView = 'active'"
>
{{ $t('workspacePanel.members.tabs.active') }}
</Button>
<Button
v-if="uiConfig.showPendingTab"
:variant="activeView === 'pending' ? 'secondary' : 'muted-textonly'"
size="md"
@click="activeView = 'pending'"
>
{{
$t(
'workspacePanel.members.tabs.pendingCount',
pendingInvites.length
)
}}
</Button>
</div>
<!-- Date column headers -->
<template v-if="activeView === 'pending'">
<Button
variant="muted-textonly"
size="sm"
class="justify-start"
@click="toggleSort('inviteDate')"
>
{{ $t('workspacePanel.members.columns.inviteDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<Button
variant="muted-textonly"
size="sm"
class="justify-start"
@click="toggleSort('expiryDate')"
>
{{ $t('workspacePanel.members.columns.expiryDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<div />
</template>
<template v-else>
<Button
variant="muted-textonly"
size="sm"
class="justify-end"
@click="toggleSort('joinDate')"
>
{{ $t('workspacePanel.members.columns.joinDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<!-- Empty cell for action column header (OWNER only) -->
<div v-if="permissions.canRemoveMembers" />
</template>
</div>
<!-- Members List -->
<div class="min-h-0 flex-1 overflow-y-auto">
<!-- Active Members -->
<template v-if="activeView === 'active'">
<!-- Current user (always pinned at top, no date) -->
<div
:class="
cn(
'grid w-full items-center rounded-lg p-2',
uiConfig.membersGridCols
)
"
>
<div class="flex items-center gap-3">
<UserAvatar
class="size-8"
:photo-url="userPhotoUrl"
:pt:icon:class="{ 'text-xl!': !userPhotoUrl }"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="text-sm text-base-foreground">
{{ userDisplayName }}
<span
v-if="isPersonalWorkspace"
class="text-muted-foreground"
>
({{ $t('g.you') }})
</span>
</span>
<div
v-if="uiConfig.showRoleBadge"
class="py-0.5 px-1.5 text-xs bg-background-muted"
>
{{ workspaceRole }}
</div>
</div>
<span class="text-sm text-muted-foreground">
{{ userEmail }}
</span>
</div>
</div>
<!-- Empty cell for grid alignment (no date for current user) -->
<span v-if="uiConfig.showDateColumn" />
<!-- Empty cell for action column (can't remove yourself) -->
<span v-if="permissions.canRemoveMembers" />
</div>
<!-- Other members (sorted) -->
<div
v-for="(member, index) in filteredMembers"
:key="member.id"
:class="
cn(
'grid w-full items-center rounded-lg p-2',
uiConfig.membersGridCols,
index % 2 === 1 && 'bg-secondary-background/50'
)
"
>
<div class="flex items-center gap-3">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary-background"
>
<span class="text-sm font-bold text-base-foreground">
{{ member.name.charAt(0).toUpperCase() }}
</span>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-sm text-base-foreground">
{{ member.name }}
</span>
<span class="text-sm text-muted-foreground">
{{ member.email }}
</span>
</div>
</div>
<!-- Join date -->
<span
v-if="uiConfig.showDateColumn"
class="text-sm text-muted-foreground text-right"
>
{{ formatDate(member.joinDate) }}
</span>
<!-- Remove member action (OWNER only) -->
<div
v-if="permissions.canRemoveMembers"
class="flex items-center justify-end"
>
<Button
v-tooltip="{
value: $t('g.moreOptions'),
showDelay: 300
}"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
@click="showMemberMenu($event, member)"
>
<i class="pi pi-ellipsis-h" />
</Button>
</div>
</div>
<!-- Member actions menu (shared for all members) -->
<Menu ref="memberMenu" :model="memberMenuItems" :popup="true" />
</template>
<!-- Pending Invites -->
<template v-else>
<div
v-for="(invite, index) in filteredPendingInvites"
:key="invite.id"
:class="
cn(
'grid w-full items-center rounded-lg p-2',
uiConfig.pendingGridCols,
index % 2 === 1 && 'bg-secondary-background/50'
)
"
>
<!-- Invite info -->
<div class="flex items-center gap-3">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary-background"
>
<span class="text-sm font-bold text-base-foreground">
{{ getInviteInitial(invite.email) }}
</span>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-sm text-base-foreground">
{{ getInviteDisplayName(invite.email) }}
</span>
<span class="text-sm text-muted-foreground">
{{ invite.email }}
</span>
</div>
</div>
<!-- Invite date -->
<span class="text-sm text-muted-foreground">
{{ formatDate(invite.inviteDate) }}
</span>
<!-- Expiry date -->
<span class="text-sm text-muted-foreground">
{{ formatDate(invite.expiryDate) }}
</span>
<!-- Actions -->
<div class="flex items-center justify-end gap-2">
<Button
v-tooltip="{
value: $t('workspacePanel.members.actions.copyLink'),
showDelay: 300
}"
variant="secondary"
size="md"
:aria-label="$t('workspacePanel.members.actions.copyLink')"
@click="handleCopyInviteLink(invite)"
>
<i class="icon-[lucide--link] size-4" />
</Button>
<Button
v-tooltip="{
value: $t('workspacePanel.members.actions.revokeInvite'),
showDelay: 300
}"
variant="secondary"
size="md"
:aria-label="$t('workspacePanel.members.actions.revokeInvite')"
@click="handleRevokeInvite(invite)"
>
<i class="icon-[lucide--mail-x] size-4" />
</Button>
</div>
</div>
<div
v-if="filteredPendingInvites.length === 0"
class="flex w-full items-center justify-center py-8 text-sm text-muted-foreground"
>
{{ $t('workspacePanel.members.noInvites') }}
</div>
</template>
</div>
</div>
</div>
<!-- Personal Workspace Message -->
<div v-if="isPersonalWorkspace" class="flex items-center">
<p class="text-sm text-muted-foreground">
{{ $t('workspacePanel.members.personalWorkspaceMessage') }}
</p>
<button
class="underline bg-transparent border-none cursor-pointer"
@click="handleCreateWorkspace"
>
{{ $t('workspacePanel.members.createNewWorkspace') }}
</button>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Menu from 'primevue/menu'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import type {
PendingInvite,
WorkspaceMember
} from '@/platform/workspace/stores/teamWorkspaceStore'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
import { cn } from '@/utils/tailwindUtil'
const { d, t } = useI18n()
const toast = useToast()
const { userPhotoUrl, userEmail, userDisplayName } = useCurrentUser()
const {
showRemoveMemberDialog,
showRevokeInviteDialog,
showCreateWorkspaceDialog
} = useDialogService()
const workspaceStore = useTeamWorkspaceStore()
const {
members,
pendingInvites,
isInPersonalWorkspace: isPersonalWorkspace
} = storeToRefs(workspaceStore)
const { fetchMembers, fetchPendingInvites, copyInviteLink } = workspaceStore
const { permissions, uiConfig, workspaceRole } = useWorkspaceUI()
const searchQuery = ref('')
const activeView = ref<'active' | 'pending'>('active')
const sortField = ref<'inviteDate' | 'expiryDate' | 'joinDate'>('inviteDate')
const sortDirection = ref<'asc' | 'desc'>('desc')
const memberMenu = ref<InstanceType<typeof Menu> | null>(null)
const selectedMember = ref<WorkspaceMember | null>(null)
function getInviteDisplayName(email: string): string {
return email.split('@')[0]
}
function getInviteInitial(email: string): string {
return email.charAt(0).toUpperCase()
}
const memberMenuItems = computed(() => [
{
label: t('workspacePanel.members.actions.removeMember'),
icon: 'pi pi-user-minus',
command: () => {
if (selectedMember.value) {
handleRemoveMember(selectedMember.value)
}
}
}
])
function showMemberMenu(event: Event, member: WorkspaceMember) {
selectedMember.value = member
memberMenu.value?.toggle(event)
}
async function refreshData() {
await Promise.all([fetchMembers(), fetchPendingInvites()])
}
onMounted(() => {
void refreshData()
})
watch(workspaceRole, () => {
// Reset to active view if pending tab is not available for this role
if (!uiConfig.value.showPendingTab) {
activeView.value = 'active'
}
void refreshData()
})
// Other members (sorted, excluding current user)
const filteredMembers = computed(() => {
let result = members.value.filter(
(member) => member.email.toLowerCase() !== userEmail.value?.toLowerCase()
)
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(
(member) =>
member.name.toLowerCase().includes(query) ||
member.email.toLowerCase().includes(query)
)
}
result.sort((a, b) => {
const aValue = a.joinDate.getTime()
const bValue = b.joinDate.getTime()
return sortDirection.value === 'asc' ? aValue - bValue : bValue - aValue
})
return result
})
const filteredPendingInvites = computed(() => {
let result = [...pendingInvites.value]
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter((invite) =>
invite.email.toLowerCase().includes(query)
)
}
const field = sortField.value as 'inviteDate' | 'expiryDate'
result.sort((a, b) => {
const aValue = a[field].getTime()
const bValue = b[field].getTime()
return sortDirection.value === 'asc' ? aValue - bValue : bValue - aValue
})
return result
})
function toggleSort(field: 'inviteDate' | 'expiryDate' | 'joinDate') {
if (sortField.value === field) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = field
sortDirection.value = 'desc'
}
}
function formatDate(date: Date): string {
return d(date, { dateStyle: 'medium' })
}
async function handleCopyInviteLink(invite: PendingInvite) {
try {
await copyInviteLink(invite.id)
toast.add({
severity: 'success',
summary: t('g.copied'),
life: 2000
})
} catch {
toast.add({
severity: 'error',
summary: t('g.error'),
life: 3000
})
}
}
function handleRevokeInvite(invite: PendingInvite) {
showRevokeInviteDialog(invite.id)
}
function handleCreateWorkspace() {
showCreateWorkspaceDialog()
}
function handleRemoveMember(member: WorkspaceMember) {
showRemoveMemberDialog(member.id)
}
</script>

View File

@@ -0,0 +1,11 @@
<template>
<TabPanel value="Workspace" class="h-full">
<WorkspacePanelContent />
</TabPanel>
</template>
<script setup lang="ts">
import TabPanel from 'primevue/tabpanel'
import WorkspacePanelContent from '@/components/dialog/content/setting/WorkspacePanelContent.vue'
</script>

View File

@@ -0,0 +1,201 @@
<template>
<div class="flex h-full w-full flex-col">
<div class="pb-8 flex items-center gap-4">
<WorkspaceProfilePic
class="size-12 !text-3xl"
:workspace-name="workspaceName"
/>
<h1 class="text-3xl text-base-foreground">
{{ workspaceName }}
</h1>
</div>
<Tabs :value="activeTab" @update:value="setActiveTab">
<div class="flex w-full items-center">
<TabList class="w-full">
<Tab value="plan">{{ $t('workspacePanel.tabs.planCredits') }}</Tab>
<Tab value="members">{{
$t('workspacePanel.tabs.membersCount', { count: members.length })
}}</Tab>
</TabList>
<Button
v-if="permissions.canInviteMembers"
v-tooltip="
inviteTooltip
? { value: inviteTooltip, showDelay: 0 }
: { value: $t('workspacePanel.inviteMember'), showDelay: 300 }
"
variant="secondary"
size="lg"
:disabled="isInviteLimitReached"
:class="isInviteLimitReached && 'opacity-50 cursor-not-allowed'"
:aria-label="$t('workspacePanel.inviteMember')"
@click="handleInviteMember"
>
{{ $t('workspacePanel.invite') }}
<i class="pi pi-plus ml-1 text-sm" />
</Button>
<template v-if="permissions.canAccessWorkspaceMenu">
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
@click="menu?.toggle($event)"
>
<i class="pi pi-ellipsis-h" />
</Button>
<Menu ref="menu" :model="menuItems" :popup="true">
<template #item="{ item }">
<div
v-tooltip="
item.disabled && deleteTooltip
? { value: deleteTooltip, showDelay: 0 }
: null
"
:class="[
'flex items-center gap-2 px-3 py-2',
item.class,
item.disabled ? 'pointer-events-auto' : ''
]"
@click="
item.command?.({
originalEvent: $event,
item
})
"
>
<i :class="item.icon" />
<span>{{ item.label }}</span>
</div>
</template>
</Menu>
</template>
</div>
<TabPanels>
<TabPanel value="plan">
<SubscriptionPanelContent />
</TabPanel>
<TabPanel value="members">
<MembersPanelContent :key="workspaceRole" />
</TabPanel>
</TabPanels>
</Tabs>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Menu from 'primevue/menu'
import Tab from 'primevue/tab'
import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import MembersPanelContent from '@/components/dialog/content/setting/MembersPanelContent.vue'
import Button from '@/components/ui/button/Button.vue'
import SubscriptionPanelContent from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
const { defaultTab = 'plan' } = defineProps<{
defaultTab?: string
}>()
const { t } = useI18n()
const {
showLeaveWorkspaceDialog,
showDeleteWorkspaceDialog,
showInviteMemberDialog,
showEditWorkspaceDialog
} = useDialogService()
const workspaceStore = useTeamWorkspaceStore()
const { workspaceName, members, isInviteLimitReached, isWorkspaceSubscribed } =
storeToRefs(workspaceStore)
const { fetchMembers, fetchPendingInvites } = workspaceStore
const { activeTab, setActiveTab, workspaceRole, permissions, uiConfig } =
useWorkspaceUI()
const menu = ref<InstanceType<typeof Menu> | null>(null)
function handleLeaveWorkspace() {
showLeaveWorkspaceDialog()
}
function handleDeleteWorkspace() {
showDeleteWorkspaceDialog()
}
function handleEditWorkspace() {
showEditWorkspaceDialog()
}
// Disable delete when workspace has an active subscription (to prevent accidental deletion)
// Use workspace's own subscription status, not the global isActiveSubscription
const isDeleteDisabled = computed(
() =>
uiConfig.value.workspaceMenuAction === 'delete' &&
isWorkspaceSubscribed.value
)
const deleteTooltip = computed(() => {
if (!isDeleteDisabled.value) return null
const tooltipKey = uiConfig.value.workspaceMenuDisabledTooltip
return tooltipKey ? t(tooltipKey) : null
})
const inviteTooltip = computed(() => {
if (!isInviteLimitReached.value) return null
return t('workspacePanel.inviteLimitReached')
})
function handleInviteMember() {
if (isInviteLimitReached.value) return
showInviteMemberDialog()
}
const menuItems = computed(() => {
const items = []
// Add edit option for owners
if (uiConfig.value.showEditWorkspaceMenuItem) {
items.push({
label: t('workspacePanel.menu.editWorkspace'),
icon: 'pi pi-pencil',
command: handleEditWorkspace
})
}
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'),
icon: 'pi pi-sign-out',
command: handleLeaveWorkspace
})
}
return items
})
onMounted(() => {
setActiveTab(defaultTab)
fetchMembers()
fetchPendingInvites()
})
</script>

View File

@@ -0,0 +1,19 @@
<template>
<div class="flex items-center gap-2">
<WorkspaceProfilePic
class="size-6 text-xs"
:workspace-name="workspaceName"
/>
<span>{{ workspaceName }}</span>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
const { workspaceName } = storeToRefs(useTeamWorkspaceStore())
</script>

View File

@@ -0,0 +1,44 @@
<template>
<Toast group="workspace-created">
<template #message>
<div class="flex w-full flex-col gap-3">
<div class="flex flex-col gap-1">
<span class="text-base font-semibold text-base-foreground">
{{ $t('workspacePanel.toast.workspaceCreated.title') }}
</span>
<span class="text-sm text-muted-foreground">
{{ $t('workspacePanel.toast.workspaceCreated.message') }}
</span>
</div>
<div class="flex items-center justify-end gap-2">
<Button variant="muted-textonly" size="sm" @click="handleDismiss">
{{ $t('g.dismiss') }}
</Button>
<Button variant="primary" size="sm" @click="handleSubscribe">
{{ $t('workspacePanel.toast.workspaceCreated.subscribe') }}
</Button>
</div>
</div>
</template>
</Toast>
</template>
<script setup lang="ts">
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import Button from '@/components/ui/button/Button.vue'
import { useDialogService } from '@/services/dialogService'
const toast = useToast()
const dialogService = useDialogService()
function handleDismiss() {
toast.removeGroup('workspace-created')
}
function handleSubscribe() {
toast.removeGroup('workspace-created')
dialogService.showSettingsDialog('workspace')
}
</script>

View File

@@ -0,0 +1,347 @@
<!-- 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(() => {
// PRO is currently the only/highest tier, so no upgrades available
// This will need updating when additional tiers are added
return false
})
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>

View File

@@ -0,0 +1,192 @@
<template>
<div class="flex w-80 flex-col overflow-hidden rounded-lg">
<div class="flex flex-col overflow-y-auto">
<!-- Loading state -->
<div v-if="isFetchingWorkspaces" class="flex flex-col gap-2 p-2">
<div
v-for="i in 2"
:key="i"
class="flex h-[54px] animate-pulse items-center gap-2 rounded px-2 py-4"
>
<div class="size-8 rounded-full bg-secondary-background" />
<div class="flex flex-1 flex-col gap-1">
<div class="h-4 w-24 rounded bg-secondary-background" />
<div class="h-3 w-16 rounded bg-secondary-background" />
</div>
</div>
</div>
<!-- Workspace list -->
<template v-else>
<template v-for="workspace in availableWorkspaces" :key="workspace.id">
<div class="border-b border-border-default p-2">
<div
:class="
cn(
'group flex h-[54px] w-full items-center gap-2 rounded px-2 py-4',
'hover:bg-secondary-background-hover',
isCurrentWorkspace(workspace) && 'bg-secondary-background'
)
"
>
<button
class="flex flex-1 cursor-pointer items-center gap-2 border-none bg-transparent p-0"
@click="handleSelectWorkspace(workspace)"
>
<WorkspaceProfilePic
class="size-8 text-sm"
:workspace-name="workspace.name"
/>
<div class="flex min-w-0 flex-1 flex-col items-start gap-1">
<span class="text-sm text-base-foreground">
{{ workspace.name }}
</span>
<span
v-if="workspace.type !== 'personal'"
class="text-sm text-muted-foreground"
>
{{ getRoleLabel(workspace.role) }}
</span>
</div>
<i
v-if="isCurrentWorkspace(workspace)"
class="pi pi-check text-sm text-base-foreground"
/>
</button>
<!-- Delete button - only for team workspaces where user is owner -->
<button
v-if="canDeleteWorkspace(workspace)"
class="flex size-6 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted-foreground opacity-0 transition-opacity hover:bg-error-background hover:text-error-foreground group-hover:opacity-100"
:title="$t('g.delete')"
@click.stop="handleDeleteWorkspace(workspace)"
>
<i class="pi pi-trash text-xs" />
</button>
</div>
</div>
</template>
</template>
<!-- <Divider class="mx-0 my-0" /> -->
<!-- Create workspace button -->
<div class="px-2 py-2">
<div
:class="
cn(
'flex h-12 w-full items-center gap-2 rounded px-2 py-2',
canCreateWorkspace
? 'cursor-pointer hover:bg-secondary-background-hover'
: 'cursor-default'
)
"
@click="canCreateWorkspace && handleCreateWorkspace()"
>
<div
:class="
cn(
'flex size-8 items-center justify-center rounded-full bg-secondary-background',
!canCreateWorkspace && 'opacity-50'
)
"
>
<i class="pi pi-plus text-sm text-muted-foreground" />
</div>
<div class="flex min-w-0 flex-1 flex-col">
<span
v-if="canCreateWorkspace"
class="text-sm text-muted-foreground"
>
{{ $t('workspaceSwitcher.createWorkspace') }}
</span>
<span v-else class="text-sm text-muted-foreground">
{{ $t('workspaceSwitcher.maxWorkspacesReached') }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
import type {
WorkspaceRole,
WorkspaceType
} from '@/platform/workspace/api/workspaceApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
import { cn } from '@/utils/tailwindUtil'
interface AvailableWorkspace {
id: string
name: string
type: WorkspaceType
role: WorkspaceRole
}
const emit = defineEmits<{
select: [workspace: AvailableWorkspace]
create: []
delete: [workspace: AvailableWorkspace]
}>()
const { t } = useI18n()
const { switchWithConfirmation } = useWorkspaceSwitch()
const { showDeleteWorkspaceDialog } = useDialogService()
const workspaceStore = useTeamWorkspaceStore()
const { workspaceId, workspaces, canCreateWorkspace, isFetchingWorkspaces } =
storeToRefs(workspaceStore)
const availableWorkspaces = computed<AvailableWorkspace[]>(() =>
workspaces.value.map((w) => ({
id: w.id,
name: w.name,
type: w.type,
role: w.role
}))
)
// Workspace store is initialized in router.ts before the app loads
// This component just displays the already-loaded workspace data
function isCurrentWorkspace(workspace: AvailableWorkspace): boolean {
return workspace.id === workspaceId.value
}
function getRoleLabel(role: AvailableWorkspace['role']): string {
if (role === 'owner') return t('workspaceSwitcher.roleOwner')
if (role === 'member') return t('workspaceSwitcher.roleMember')
return ''
}
async function handleSelectWorkspace(workspace: AvailableWorkspace) {
const success = await switchWithConfirmation(workspace.id)
if (success) {
emit('select', workspace)
}
}
function handleCreateWorkspace() {
emit('create')
}
function canDeleteWorkspace(workspace: AvailableWorkspace): boolean {
// Can only delete team workspaces where user is owner
return workspace.type === 'team' && workspace.role === 'owner'
}
function handleDeleteWorkspace(workspace: AvailableWorkspace) {
showDeleteWorkspaceDialog({
workspaceId: workspace.id,
workspaceName: workspace.name
})
emit('delete', workspace)
}
</script>

View File

@@ -1,6 +1,7 @@
{
"g": {
"user": "User",
"you": "You",
"currentUser": "Current user",
"empty": "Empty",
"noWorkflowsFound": "No workflows found.",
@@ -1264,7 +1265,10 @@
"Scene": "Scene",
"3D": "3D",
"Light": "Light",
"User": "User",
"Profile": "Profile",
"Workspace": "Workspace",
"WorkspacePlan": "Plan & Credits",
"WorkspaceMembers": "Members",
"Credits": "Credits",
"API Nodes": "API Nodes",
"Notification Preferences": "Notification Preferences",
@@ -1997,6 +2001,8 @@
"renewsDate": "Renews {date}",
"expiresDate": "Expires {date}",
"manageSubscription": "Manage subscription",
"managePayment": "Manage Payment",
"cancelSubscription": "Cancel Subscription",
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
"partnerNodesDescription": "For running commercial/proprietary models",
"totalCredits": "Total credits",
@@ -2051,6 +2057,9 @@
"subscribeToRunFull": "Subscribe to Run",
"subscribeNow": "Subscribe Now",
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
"workspaceNotSubscribed": "This workspace is not on a subscription",
"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",
"haveQuestions": "Have questions or wondering about enterprise?",
"contactUs": "Contact us",
@@ -2086,12 +2095,132 @@
"userSettings": {
"title": "My Account Settings",
"accountSettings": "Account settings",
"workspaceSettings": "Workspace settings",
"name": "Name",
"email": "Email",
"provider": "Sign-in Provider",
"notSet": "Not set",
"updatePassword": "Update Password"
},
"workspacePanel": {
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of 50 members",
"tabs": {
"dashboard": "Dashboard",
"planCredits": "Plan & Credits",
"membersCount": "Members ({count})"
},
"dashboard": {
"placeholder": "Dashboard workspace settings"
},
"members": {
"membersCount": "{count}/50 Members",
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
"tabs": {
"active": "Active",
"pendingCount": "Pending ({count})"
},
"columns": {
"inviteDate": "Invite date",
"expiryDate": "Expiry date",
"joinDate": "Join date"
},
"actions": {
"copyLink": "Copy invite link",
"revokeInvite": "Revoke invite",
"removeMember": "Remove member"
},
"noInvites": "No pending invites",
"noMembers": "No members",
"personalWorkspaceMessage": "You can't invite other members to your personal workspace right now. To add members to a workspace,",
"createNewWorkspace": "create a new one."
},
"menu": {
"editWorkspace": "Edit workspace details",
"leaveWorkspace": "Leave Workspace",
"deleteWorkspace": "Delete Workspace",
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first"
},
"editWorkspaceDialog": {
"title": "Edit workspace details",
"nameLabel": "Workspace name",
"save": "Save"
},
"leaveDialog": {
"title": "Leave this workspace?",
"message": "You won't be able to join again unless you contact the workspace owner.",
"leave": "Leave"
},
"deleteDialog": {
"title": "Delete this workspace?",
"message": "Any unused credits or unsaved assets will be lost. This action cannot be undone.",
"messageWithName": "Delete \"{name}\"? Any unused credits or unsaved assets will be lost. This action cannot be undone."
},
"removeMemberDialog": {
"title": "Remove this member?",
"message": "This member will be removed from your workspace. Credits they've used will not be refunded.",
"remove": "Remove member"
},
"revokeInviteDialog": {
"title": "Uninvite this person?",
"message": "This member won't be able to join your workspace anymore. Their invite link will be invalidated.",
"revoke": "Uninvite"
},
"inviteMemberDialog": {
"title": "Invite a person to this workspace",
"message": "Create a shareable invite link to send to someone",
"placeholder": "Enter the person's email",
"createLink": "Create link",
"linkStep": {
"title": "Send this link to the person",
"message": "Make sure their account uses this email.",
"copyLink": "Copy Link",
"done": "Done"
},
"linkCopied": "Copied",
"linkCopyFailed": "Failed to copy link"
},
"createWorkspaceDialog": {
"title": "Create a new workspace",
"message": "Workspaces let members share a single credits pool. You'll become the owner after creating this.",
"nameLabel": "Workspace name*",
"namePlaceholder": "Enter workspace name",
"create": "Create"
},
"toast": {
"workspaceCreated": {
"title": "Workspace created",
"message": "Subscribe to a plan, invite teammates, and start collaborating.",
"subscribe": "Subscribe"
},
"workspaceUpdated": {
"title": "Workspace updated",
"message": "Workspace details have been saved."
},
"workspaceDeleted": {
"title": "Workspace deleted",
"message": "The workspace has been permanently deleted."
},
"workspaceLeft": {
"title": "Left workspace",
"message": "You have left the workspace."
},
"failedToUpdateWorkspace": "Failed to update workspace",
"failedToCreateWorkspace": "Failed to create workspace",
"failedToDeleteWorkspace": "Failed to delete workspace",
"failedToLeaveWorkspace": "Failed to leave workspace",
"failedToFetchWorkspaces": "Failed to load workspaces"
}
},
"workspaceSwitcher": {
"switchWorkspace": "Switch workspace",
"subscribe": "Subscribe",
"roleOwner": "Owner",
"roleMember": "Member",
"createWorkspace": "Create new workspace",
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one."
},
"selectionToolbox": {
"executeButton": {
"tooltip": "Execute to selected output nodes (Highlighted with orange border)",
@@ -2608,7 +2737,10 @@
"unsavedChanges": {
"title": "Unsaved Changes",
"message": "You have unsaved changes. Do you want to discard them and switch workspaces?"
}
},
"inviteAccepted": "Invite Accepted",
"addedToWorkspace": "You have been added to {workspaceName}",
"inviteFailed": "Failed to Accept Invite"
},
"workspaceAuth": {
"errors": {
@@ -2619,4 +2751,4 @@
"tokenExchangeFailed": "Failed to authenticate with workspace: {error}"
}
}
}
}

View File

@@ -17,208 +17,10 @@
</div>
</div>
<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-else
: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="usageHistoryUrl"
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>
<!-- Workspace mode: workspace-aware subscription content -->
<SubscriptionPanelContentWorkspace v-if="teamWorkspacesEnabled" />
<!-- Legacy mode: user-level subscription content -->
<SubscriptionPanelContentLegacy v-else />
<div
class="flex items-center justify-between border-t border-interface-stroke pt-3"
@@ -265,171 +67,32 @@
</template>
<script setup lang="ts">
import Skeleton from 'primevue/skeleton'
import TabPanel from 'primevue/tabpanel'
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { defineAsyncComponent } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import SubscriptionPanelContentLegacy from '@/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.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'
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 authActions = useFirebaseAuthActions()
const { t, n } = useI18n()
const {
isActiveSubscription,
isCancelled,
formattedRenewalDate,
formattedEndDate,
subscriptionTier,
subscriptionTierName,
subscriptionStatus,
isYearlySubscription,
handleInvoiceHistory
} = useSubscription()
const { isActiveSubscription, handleInvoiceHistory } = 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 usageHistoryUrl = computed(
() => `${getComfyPlatformBaseUrl()}/profile/usage`
)
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 {
isLoadingSupport,
handleAddApiCredits,
handleMessageSupport,
handleRefresh,
handleLearnMoreClick
} = 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)
})
const { isLoadingSupport, handleMessageSupport, handleLearnMoreClick } =
useSubscriptionActions()
const handleOpenPartnerNodesInfo = () => {
window.open(
@@ -438,9 +101,3 @@ const handleOpenPartnerNodesInfo = () => {
)
}
</script>
<style scoped>
:deep(.bg-comfy-menu-secondary) {
background-color: transparent;
}
</style>

View File

@@ -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>

View File

@@ -0,0 +1,435 @@
<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">
<!-- OWNER Unsubscribed State -->
<template v-if="isOwnerUnsubscribed">
<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.subscriptionRequiredMessage') }}
</div>
</div>
<Button
variant="primary"
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal"
@click="handleSubscribeWorkspace"
>
{{ $t('subscription.subscribeNow') }}
</Button>
</template>
<!-- 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>
<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>
<template
v-if="isActiveSubscription && permissions.canManageSubscription"
>
<Button
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.managePayment') }}
</Button>
<Button
variant="primary"
class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary"
@click="showSubscriptionDialog"
>
{{ $t('subscription.upgradePlan') }}
</Button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
@click="planMenu?.toggle($event)"
>
<i class="pi pi-ellipsis-h" />
</Button>
<Menu ref="planMenu" :model="planMenuItems" :popup="true" />
</template>
</template>
</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">
{{ showZeroState ? '0' : 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>{{
showZeroState ? '0 / 0' : 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>{{
showZeroState ? '0' : 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 && !showZeroState"
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 { storeToRefs } from 'pinia'
import Menu from 'primevue/menu'
import Skeleton from 'primevue/skeleton'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
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 { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@/utils/tailwindUtil'
const authActions = useFirebaseAuthActions()
const workspaceStore = useTeamWorkspaceStore()
const { isWorkspaceSubscribed } = storeToRefs(workspaceStore)
const { subscribeWorkspace } = workspaceStore
const { permissions, workspaceRole } = useWorkspaceUI()
const { t, n } = useI18n()
// OWNER with unsubscribed workspace - can see subscribe button
const isOwnerUnsubscribed = computed(
() => 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
function handleSubscribeWorkspace() {
subscribeWorkspace('PRO_MONTHLY')
}
const {
isActiveSubscription,
isCancelled,
formattedRenewalDate,
formattedEndDate,
subscriptionTier,
subscriptionTierName,
subscriptionStatus,
isYearlySubscription
} = useSubscription()
const { show: showSubscriptionDialog } = useSubscriptionDialog()
const planMenu = ref<InstanceType<typeof Menu> | null>(null)
const planMenuItems = computed(() => [
{
label: t('subscription.cancelSubscription'),
icon: 'pi pi-times',
command: async () => {
await authActions.accessBillingPortal()
}
}
])
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>

View File

@@ -1,6 +1,18 @@
<template>
<div class="settings-container">
<ScrollPanel class="settings-sidebar w-48 shrink-0 p-2 2xl:w-64">
<div
: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
v-model:model-value="searchQuery"
class="settings-search-box mb-2 w-full"
@@ -20,16 +32,40 @@
(option: SettingTreeNode) =>
!queryIsEmpty && !searchResultsCategories.has(option.label ?? '')
"
class="w-full border-none"
:class="
teamWorkspacesEnabled
? 'w-full border-none bg-transparent'
: 'w-full border-none'
"
>
<template #optiongroup>
<!-- Workspace mode: custom group headers -->
<template v-if="teamWorkspacesEnabled" #optiongroup="{ option }">
<h3 class="text-xs font-semibold uppercase text-muted m-0 pt-6 pb-2">
{{ option.label }}
</h3>
</template>
<!-- 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'" />
<span v-else>{{ option.translatedLabel }}</span>
</template>
</Listbox>
</ScrollPanel>
<Divider layout="vertical" class="mx-1 hidden md:flex 2xl:mx-4" />
<Divider layout="horizontal" class="flex md:hidden" />
<Tabs :value="tabValue" :lazy="true" class="settings-content h-full w-full">
<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">
<PanelTemplate value="Search Results">
<SettingsPanel :setting-groups="searchResults" />
@@ -48,7 +84,7 @@
</PanelTemplate>
<Suspense v-for="panel in panels" :key="panel.node.key">
<component :is="panel.component" />
<component :is="panel.component" v-bind="panel.props" />
<template #fallback>
<div>{{ $t('g.loadingPanel', { panel: panel.node.label }) }}</div>
</template>
@@ -69,7 +105,10 @@ import { computed, watch } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserMessage.vue'
import PanelTemplate from '@/components/dialog/content/setting/PanelTemplate.vue'
import WorkspaceSidebarItem from '@/components/dialog/content/setting/WorkspaceSidebarItem.vue'
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 SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
@@ -86,8 +125,13 @@ const { defaultPanel } = defineProps<{
| 'server-config'
| 'user'
| 'credits'
| 'subscription'
| 'workspace'
}>()
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = isCloud && flags.teamWorkspacesEnabled
const {
activeCategory,
defaultCategory,
@@ -162,6 +206,7 @@ watch(activeCategory, (_, oldValue) => {
</style>
<style scoped>
/* Legacy mode styles (when teamWorkspacesEnabled is false) */
.settings-container {
display: flex;
height: 70vh;
@@ -190,7 +235,7 @@ watch(activeCategory, (_, oldValue) => {
}
}
/* Hide the first group separator */
/* Hide the first group separator in legacy mode */
.settings-sidebar :deep(.p-listbox-option-group:nth-child(1)) {
display: none;
}

View File

@@ -3,19 +3,21 @@ import type { Component } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingParams } from '@/platform/settings/types'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isElectron } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
interface SettingPanelItem {
node: SettingTreeNode
component: Component
props?: Record<string, unknown>
}
export function useSettingUI(
@@ -27,15 +29,19 @@ export function useSettingUI(
| 'user'
| 'credits'
| 'subscription'
| 'workspace'
) {
const { t } = useI18n()
const { isLoggedIn } = useCurrentUser()
const settingStore = useSettingStore()
const activeCategory = ref<SettingTreeNode | null>(null)
const { flags } = useFeatureFlags()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const { isActiveSubscription } = useSubscription()
const teamWorkspacesEnabled = isCloud && flags.teamWorkspacesEnabled
const settingRoot = computed<SettingTreeNode>(() => {
const root = buildTree(
Object.values(settingStore.settingsById).filter(
@@ -64,6 +70,33 @@ export function useSettingUI(
() => settingRoot.value.children ?? []
)
// Core setting categories (built-in to ComfyUI) in display order
// 'Other' includes floating settings that don't have a specific category
const CORE_CATEGORIES_ORDER = [
'Comfy',
'LiteGraph',
'Appearance',
'3D',
'Mask Editor',
'Other'
]
const CORE_CATEGORIES = new Set(CORE_CATEGORIES_ORDER)
const coreSettingCategories = computed<SettingTreeNode[]>(() => {
const categories = settingCategories.value.filter((node) =>
CORE_CATEGORIES.has(node.label)
)
return categories.sort(
(a, b) =>
CORE_CATEGORIES_ORDER.indexOf(a.label) -
CORE_CATEGORIES_ORDER.indexOf(b.label)
)
})
const customNodeSettingCategories = computed<SettingTreeNode[]>(() =>
settingCategories.value.filter((node) => !CORE_CATEGORIES.has(node.label))
)
// Define panel items
const aboutPanel: SettingPanelItem = {
node: {
@@ -118,6 +151,25 @@ export function useSettingUI(
)
}
// Workspace panel: only available on cloud with team workspaces enabled
const workspacePanel: SettingPanelItem | null = !teamWorkspacesEnabled
? null
: {
node: {
key: 'workspace',
label: 'Workspace',
children: []
},
component: defineAsyncComponent(
() => import('@/components/dialog/content/setting/WorkspacePanel.vue')
)
}
const shouldShowWorkspacePanel = computed(() => {
if (!workspacePanel) return false
return isLoggedIn.value
})
const keybindingPanel: SettingPanelItem = {
node: {
key: 'keybinding',
@@ -156,13 +208,16 @@ export function useSettingUI(
aboutPanel,
creditsPanel,
userPanel,
...(shouldShowWorkspacePanel.value && workspacePanel
? [workspacePanel]
: []),
keybindingPanel,
extensionPanel,
...(isElectron() ? [serverConfigPanel] : []),
...(shouldShowPlanCreditsPanel.value && subscriptionPanel
? [subscriptionPanel]
: [])
].filter((panel) => panel.component)
].filter((panel) => panel !== null && panel.component)
)
/**
@@ -186,7 +241,49 @@ export function useSettingUI(
)
})
const groupedMenuTreeNodes = computed<SettingTreeNode[]>(() => [
// Sidebar structure when team workspaces is enabled
const workspaceMenuTreeNodes = computed<SettingTreeNode[]>(() => [
// Workspace settings
{
key: 'workspace',
label: 'Workspace',
children: [
...(shouldShowWorkspacePanel.value && workspacePanel
? [workspacePanel.node]
: []),
...(isLoggedIn.value &&
!(isCloud && window.__CONFIG__?.subscription_required)
? [creditsPanel.node]
: [])
].map(translateCategory)
},
// General settings - Profile + all core settings + special panels
{
key: 'general',
label: 'General',
children: [
translateCategory(userPanel.node),
...coreSettingCategories.value.map(translateCategory),
translateCategory(keybindingPanel.node),
translateCategory(extensionPanel.node),
translateCategory(aboutPanel.node),
...(isElectron() ? [translateCategory(serverConfigPanel.node)] : [])
]
},
// Custom node settings (only shown if custom nodes have registered settings)
...(customNodeSettingCategories.value.length > 0
? [
{
key: 'other',
label: 'Other',
children: customNodeSettingCategories.value.map(translateCategory)
}
]
: [])
])
// 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',
@@ -223,6 +320,12 @@ export function useSettingUI(
}
])
const groupedMenuTreeNodes = computed<SettingTreeNode[]>(() =>
teamWorkspacesEnabled
? workspaceMenuTreeNodes.value
: legacyMenuTreeNodes.value
)
onMounted(() => {
activeCategory.value = defaultCategory.value
})

View File

@@ -102,6 +102,7 @@ export const useDialogService = () => {
| 'user'
| 'credits'
| 'subscription'
| 'workspace'
) {
const props = panel ? { props: { defaultPanel: panel } } : undefined
@@ -519,6 +520,113 @@ export const useDialogService = () => {
show()
}
// 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({
key: 'leave-workspace',
component,
dialogComponentProps: workspaceDialogPt
})
}
async function showDeleteWorkspaceDialog(options?: {
workspaceId?: string
workspaceName?: string
}) {
const { default: component } =
await import('@/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue')
return dialogStore.showDialog({
key: 'delete-workspace',
component,
props: options,
dialogComponentProps: workspaceDialogPt
})
}
async function showRemoveMemberDialog(memberId: string) {
const { default: component } =
await import('@/components/dialog/content/workspace/RemoveMemberDialogContent.vue')
return dialogStore.showDialog({
key: 'remove-member',
component,
props: { memberId },
dialogComponentProps: workspaceDialogPt
})
}
async function showRevokeInviteDialog(inviteId: string) {
const { default: component } =
await import('@/components/dialog/content/workspace/RevokeInviteDialogContent.vue')
return dialogStore.showDialog({
key: 'revoke-invite',
component,
props: { inviteId },
dialogComponentProps: workspaceDialogPt
})
}
async function showInviteMemberDialog() {
const { default: component } =
await import('@/components/dialog/content/workspace/InviteMemberDialogContent.vue')
return dialogStore.showDialog({
key: 'invite-member',
component,
dialogComponentProps: {
...workspaceDialogPt,
pt: {
...workspaceDialogPt.pt,
root: { class: 'rounded-2xl max-w-[512px] w-full' }
}
}
})
}
async function showCreateWorkspaceDialog(
onConfirm?: (name: string) => void | Promise<void>
) {
const { default: component } =
await import('@/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue')
return dialogStore.showDialog({
key: 'create-workspace',
component,
props: { onConfirm },
dialogComponentProps: {
...workspaceDialogPt,
pt: {
...workspaceDialogPt.pt,
root: { class: 'rounded-2xl max-w-[400px] w-full' }
}
}
})
}
async function showEditWorkspaceDialog() {
const { default: component } =
await import('@/components/dialog/content/workspace/EditWorkspaceDialogContent.vue')
return dialogStore.showDialog({
key: 'edit-workspace',
component,
dialogComponentProps: {
...workspaceDialogPt,
pt: {
...workspaceDialogPt.pt,
root: { class: 'rounded-2xl max-w-[400px] w-full' }
}
}
})
}
return {
showLoadWorkflowWarning,
showMissingModelsWarning,
@@ -530,6 +638,13 @@ export const useDialogService = () => {
showSubscriptionRequiredDialog,
showTopUpCreditsDialog,
showUpdatePasswordDialog,
showLeaveWorkspaceDialog,
showDeleteWorkspaceDialog,
showRemoveMemberDialog,
showRevokeInviteDialog,
showInviteMemberDialog,
showCreateWorkspaceDialog,
showEditWorkspaceDialog,
showExtensionDialog,
prompt,
showErrorDialog,