Compare commits

...

1 Commits

Author SHA1 Message Date
comfydesigner
85f9a5347d feat: Plan & Credits settings tabs
Replaces the combined Workspace panel with the dedicated Plan & Credits
entry (keeping the 'workspace' key so deep links land) hosting Credits
and Invoices tabs, role-gated: Invoices is billing-managers only. The
Invoices tab surfaces the upcoming charge with a Full invoice history
link to the Stripe portal — invoice history and downloads live in
Stripe by design; the banner stays hidden until the API exposes the
upcoming amount. On the paused state the shared status banner hosts the
history link instead so banners never stack. Sidebar icons: receipt for
Plan & Credits, users for Members.
2026-07-10 15:08:56 -07:00
15 changed files with 731 additions and 237 deletions

View File

@@ -59,9 +59,6 @@ const config: KnipConfig = {
// + split/allowlist (Pagination); each consumer removes its entry
'src/components/ui/switch/Switch.vue',
'src/components/ui/pagination/Pagination.vue',
// Pending integration: consumed by split/plan-credits-tabs (Overview) and
// split/allowlist (Models); each consumer removes this entry
'src/platform/workspace/composables/useAutoPageSize.ts',
// Marketing media tooling — adopted by pages in a follow-up PR
'apps/website/src/components/common/SiteVideo.vue',
'apps/website/src/utils/marketingImage.ts',

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import { TabsRoot, useForwardPropsEmits } from 'reka-ui'
import type { TabsRootEmits, TabsRootProps } from 'reka-ui'
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
const props = defineProps<TabsRootProps>()
const emits = defineEmits<TabsRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<TabsRoot v-bind="forwarded">
<slot />
</TabsRoot>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { TabsList } from 'reka-ui'
import type { TabsListProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className, ...rest } = defineProps<
TabsListProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<TabsList
v-bind="rest"
:class="cn('inline-flex items-center gap-4', className)"
>
<slot />
</TabsList>
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { TabsTrigger, useForwardProps } from 'reka-ui'
import type { TabsTriggerProps } from 'reka-ui'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className, ...rest } = defineProps<
TabsTriggerProps & { class?: HTMLAttributes['class'] }
>()
const forwarded = useForwardProps(computed(() => rest))
</script>
<template>
<TabsTrigger
v-bind="forwarded"
:class="
cn(
'cursor-pointer appearance-none border-0 border-b-2 border-transparent bg-transparent px-0 pb-2 text-sm text-muted-foreground transition-colors outline-none data-[state=active]:border-base-foreground data-[state=active]:text-base-foreground',
className
)
"
>
<slot />
</TabsTrigger>
</template>

View File

@@ -3058,6 +3058,17 @@
"recentActivity": "Recent activity",
"topSpenders": "Top spenders",
"user": "User"
},
"nextInvoice": "Next month invoice",
"usd": "USD"
},
"invoices": {
"fullHistory": "Full invoice history"
},
"planCredits": {
"tabs": {
"invoices": "Invoices",
"overview": "Credits"
}
}
},

View File

@@ -233,5 +233,12 @@ describe('useSettingUI', () => {
}
}
})
it('splits the workspace panel into plan and members sidebar entries', () => {
const { navGroups } = useSettingUI()
const keys = navKeys(navGroups.value)
expect(keys).toContain('workspace')
expect(keys).toContain('workspace-members')
})
})
})

View File

@@ -27,13 +27,14 @@ const CATEGORY_ICONS: Record<string, string> = {
keybinding: 'icon-[lucide--keyboard]',
LiteGraph: 'icon-[lucide--workflow]',
'Mask Editor': 'icon-[lucide--pen-tool]',
Members: 'icon-[lucide--users]',
Other: 'icon-[lucide--ellipsis]',
PlanCredits: 'icon-[lucide--credit-card]',
PlanCredits: 'icon-[lucide--receipt-text]',
secrets: 'icon-[lucide--key-round]',
'server-config': 'icon-[lucide--server]',
subscription: 'icon-[lucide--credit-card]',
user: 'icon-[lucide--user]',
workspace: 'icon-[lucide--building-2]'
workspace: 'icon-[lucide--receipt-text]'
}
interface SettingPanelItem {
@@ -175,16 +176,18 @@ export function useSettingUI(
)
}
// Workspace panel: only available on cloud with team workspaces enabled
const workspacePanel: SettingPanelItem = {
// Workspace panels: only available on cloud with team workspaces enabled.
// The old single "Workspace" panel is split into three sidebar entries; the
// Plan & Credits entry keeps the 'workspace' key so existing deep links land.
const planCreditsPanel: SettingPanelItem = {
node: {
key: 'workspace',
label: 'Workspace',
label: 'PlanCredits',
children: []
},
component: defineAsyncComponent(
() =>
import('@/platform/workspace/components/dialogs/settings/WorkspacePanelContent.vue')
import('@/platform/workspace/components/dialogs/settings/PlanCreditsPanelContent.vue')
)
}
@@ -257,7 +260,9 @@ export function useSettingUI(
aboutPanel,
creditsPanel,
userPanel,
...(shouldShowWorkspacePanel.value ? [workspacePanel, membersPanel] : []),
...(shouldShowWorkspacePanel.value
? [planCreditsPanel, membersPanel]
: []),
keybindingPanel,
extensionPanel,
...(isDesktop ? [serverConfigPanel] : []),
@@ -308,9 +313,12 @@ export function useSettingUI(
label: 'Workspace',
children: [
...(shouldShowWorkspacePanel.value
? [workspacePanel.node, membersPanel.node]
? [planCreditsPanel.node, membersPanel.node]
: []),
...(isLoggedIn.value &&
// The legacy per-account Credits panel is redundant once the workspace
// Plan & Credits panel is present, which now owns the credit balance.
...(!shouldShowWorkspacePanel.value &&
isLoggedIn.value &&
!(isCloud && window.__CONFIG__?.subscription_required)
? [creditsPanel.node]
: [])

View File

@@ -0,0 +1,84 @@
<template>
<div
:class="
cn(
'@container flex min-h-0 flex-1 flex-col gap-4',
// The panel runs flush to the bottom edge (BaseModalLayout 'flush');
// Invoices keeps its prior bottom gap, Overview doesn't.
activeView !== 'overview' && 'pb-6'
)
"
>
<div class="flex w-full items-center gap-2">
<Button
v-for="tab in tabs"
:key="tab.key"
:variant="activeView === tab.key ? 'secondary' : 'muted-textonly'"
size="lg"
@click="setView(tab.key)"
>
{{ tab.label }}
</Button>
</div>
<BillingStatusBanner>
<template #actions>
<Button
v-if="activeView === 'invoices' && isPaused"
variant="textonly"
size="lg"
@click="openInvoiceHistory"
>
{{ $t('workspacePanel.invoices.fullHistory') }}
<i class="icon-[lucide--external-link] size-4" />
</Button>
</template>
</BillingStatusBanner>
<WorkspaceOverviewContent v-if="activeView === 'overview'" />
<WorkspaceInvoicesContent v-else />
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
import WorkspaceOverviewContent from '@/platform/workspace/components/dialogs/settings/WorkspaceOverviewContent.vue'
import WorkspaceInvoicesContent from '@/platform/workspace/components/dialogs/settings/WorkspaceInvoicesContent.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { cn } from '@comfyorg/tailwind-utils'
type View = 'overview' | 'invoices'
const { t } = useI18n()
const { permissions } = useWorkspaceUI()
const { isPaused, manageSubscription } = useBillingContext()
function openInvoiceHistory() {
void manageSubscription()
}
const tabs = computed(() => {
const base: { key: View; label: string }[] = [
{ key: 'overview', label: t('workspacePanel.planCredits.tabs.overview') }
]
// Invoices are billing details — owners/admins only.
if (permissions.value.canManageSubscription) {
base.push({
key: 'invoices',
label: t('workspacePanel.planCredits.tabs.invoices')
})
}
return base
})
const activeView = ref<View>('overview')
function setView(view: View) {
activeView.value = view
}
</script>

View File

@@ -0,0 +1,58 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-4">
<!-- Invoice history and downloads live in Stripe, so this tab just surfaces
the upcoming charge and links out; the local history table is hidden
for now. When paused there's no upcoming invoice — the parent's
"Subscription paused" banner covers that state and hosts the history
link, so we drop this banner to avoid stacking two. Hidden until the
API can supply the upcoming amount. -->
<div
v-if="!isPaused && nextInvoiceCents !== null"
class="flex flex-col gap-4 rounded-2xl border border-interface-stroke/60 p-4 @2xl:flex-row @2xl:items-center @2xl:justify-between"
>
<div class="flex flex-col gap-2">
<span class="text-sm text-muted-foreground">
{{ $t('workspacePanel.overview.nextInvoice') }}
</span>
<p class="m-0 text-2xl font-semibold text-base-foreground">
{{ formatWholeUsd(nextInvoiceCents) }}
<span class="text-base font-normal text-base-foreground">
{{ $t('workspacePanel.overview.usd') }}
</span>
</p>
</div>
<Button variant="secondary" size="lg" @click="openHistory">
{{ $t('workspacePanel.invoices.fullHistory') }}
<i class="icon-[lucide--external-link] size-4" />
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useWorkspaceInvoices } from '@/platform/workspace/composables/useWorkspaceInvoices'
const { n } = useI18n()
const { isPaused, manageSubscription } = useBillingContext()
const { nextInvoiceCents } = useWorkspaceInvoices()
function formatWholeUsd(cents: number | null): string {
if (cents === null) return ''
return n(cents / 100, {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0
})
}
function openHistory() {
void manageSubscription()
}
</script>

View File

@@ -0,0 +1,385 @@
<template>
<div
class="flex min-h-0 scrollbar-hide flex-1 flex-col gap-4 overflow-y-auto pb-6"
>
<!-- Plan + credits + member snapshot -->
<div
class="flex flex-col gap-6 rounded-2xl border border-interface-stroke/60 p-6"
>
<!-- Lapsed team/enterprise plan: reactivation header replaces the live one -->
<div
v-if="isInactive"
class="flex flex-col gap-4 @4xl:flex-row @4xl:items-start @4xl:justify-between"
>
<div class="flex flex-col gap-1">
<span class="text-sm text-base-foreground">
{{ inactiveTitle }}
</span>
<span class="text-sm text-muted-foreground">
{{ inactiveSubtitle }}
</span>
</div>
<div v-if="canManageBilling" class="flex shrink-0 items-center gap-2">
<Button variant="secondary" size="lg" @click="manageSubscription">
{{ $t('workspacePanel.overview.managePayment') }}
</Button>
<Button
variant="inverted"
size="lg"
:loading="isResubscribing"
@click="handleResubscribe"
>
{{ $t('workspacePanel.overview.inactive.reactivate') }}
</Button>
</div>
</div>
<div
v-else
class="flex flex-col gap-4 @4xl:flex-row @4xl:items-start @4xl:justify-between"
>
<div class="flex flex-col gap-1">
<span class="text-sm text-base-foreground">{{ plan.name }}</span>
<p
v-if="canManageBilling"
class="m-0 flex items-center gap-1.5 text-base font-semibold whitespace-nowrap text-base-foreground"
>
<i class="icon-[lucide--coins] size-4 text-credit" />
{{ plan.monthlyCredits.toLocaleString() }}
<span class="text-base font-normal text-muted-foreground">
/ {{ $t('workspacePanel.overview.perMonth') }}
</span>
</p>
<span class="text-sm text-muted-foreground">
{{
isPaused
? $t('workspacePanel.overview.paused')
: $t('workspacePanel.overview.renewsOn', {
date: plan.renewalLabel
})
}}
</span>
</div>
<div v-if="canManageBilling" class="flex shrink-0 items-center gap-2">
<Button variant="secondary" size="lg" @click="manageSubscription">
{{ $t('workspacePanel.overview.managePayment') }}
</Button>
<Button
v-if="isOriginalOwner"
variant="secondary"
size="lg"
@click="handleChangePlan"
>
{{ $t('workspacePanel.overview.changePlan') }}
</Button>
<DropdownMenu
v-if="planMenuEntries.length > 0"
:entries="planMenuEntries"
:modal="false"
>
<template #button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="secondary"
size="icon-lg"
class="rounded-lg"
:aria-label="$t('g.moreOptions')"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</DropdownMenu>
</div>
</div>
<div class="grid grid-cols-1 gap-4 @4xl:grid-cols-2">
<CreditsTile class="border-0" :frozen="isInactive" />
<!-- Member snapshot tile (hidden while the plan is lapsed) -->
<div
v-if="canManageBilling && !isInactive"
class="flex flex-col gap-3 rounded-xl bg-modal-panel-background px-6 py-5"
>
<Tabs v-model="snapshotView">
<TabsList>
<TabsTrigger value="top">
{{ $t('workspacePanel.overview.snapshot.topSpenders') }}
</TabsTrigger>
<TabsTrigger value="recent">
{{ $t('workspacePanel.overview.snapshot.recentActivity') }}
</TabsTrigger>
</TabsList>
</Tabs>
<div
v-if="snapshotEmptyMessage"
class="flex flex-1 flex-col items-center justify-center gap-3 py-6 text-center"
>
<i class="icon-[lucide--coins] size-6 text-muted-foreground" />
<p class="m-0 text-sm text-base-foreground">
{{ snapshotEmptyMessage }}
</p>
</div>
<div
v-else
ref="snapshotContainer"
class="min-h-0 flex-1 overflow-hidden"
>
<Table>
<TableHeader>
<TableRow
class="hover:bg-transparent [&>th]:h-9 [&>th]:border-b [&>th]:border-interface-stroke/60"
>
<TableHead>
{{ $t('workspacePanel.overview.snapshot.user') }}
</TableHead>
<TableHead>
{{ $t('workspacePanel.overview.snapshot.lastActivity') }}
</TableHead>
<TableHead class="text-right">
<span class="inline-flex items-center gap-1">
<i class="icon-[lucide--coins] size-4" />
{{ $t('workspacePanel.overview.snapshot.creditsUsed') }}
</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="row in snapshotRows"
:key="row.userName"
class="hover:bg-transparent [&:last-child>td]:border-b-0 [&>td]:border-b [&>td]:border-interface-stroke/20"
>
<TableCell>
<div class="flex items-center gap-2">
<span
class="flex size-5 shrink-0 items-center justify-center rounded-full"
:style="{ backgroundColor: row.color }"
>
<span class="text-2xs font-bold text-base-foreground">
{{ row.userName.charAt(0).toUpperCase() }}
</span>
</span>
<span class="text-sm text-base-foreground">
{{ row.userName }}
</span>
</div>
</TableCell>
<TableCell class="text-sm text-muted-foreground tabular-nums">
{{ row.lastActivity }}
</TableCell>
<TableCell
class="text-right text-sm text-base-foreground tabular-nums"
>
{{ row.credits.toLocaleString() }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<Button
variant="tertiary"
size="lg"
class="mt-auto w-full"
@click="handleSeeMore"
>
{{ $t('workspacePanel.overview.seeMore') }}
</Button>
</div>
</div>
</div>
<!-- mt-auto floats the footer to the panel's bottom edge; pb-6 (matching
the other tabs) keeps it level with their footers. -->
<div
class="mt-auto flex shrink-0 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground @2xl:h-8"
>
<a
:href="learnMoreUrl"
target="_blank"
rel="noopener noreferrer"
class="flex cursor-pointer items-center gap-1 text-muted-foreground no-underline transition-colors hover:text-base-foreground"
>
<i class="icon-[lucide--circle-help] size-4" />
{{ $t('workspacePanel.overview.learnMore') }}
</a>
<a
:href="partnerNodesPricingUrl"
target="_blank"
rel="noopener noreferrer"
class="flex cursor-pointer items-center gap-1 text-muted-foreground no-underline transition-colors hover:text-base-foreground"
>
<i class="icon-[lucide--circle-help] size-4" />
{{ $t('workspacePanel.overview.pricingTable') }}
</a>
<button
class="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 font-[inherit] text-sm text-muted-foreground transition-colors hover:text-base-foreground"
@click="openSupport"
>
<i class="icon-[lucide--message-circle] size-4" />
{{ $t('workspacePanel.overview.messageSupport') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { MenuItem } from 'primevue/menuitem'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import Button from '@/components/ui/button/Button.vue'
import Table from '@/components/ui/table/Table.vue'
import TableBody from '@/components/ui/table/TableBody.vue'
import TableCell from '@/components/ui/table/TableCell.vue'
import TableHead from '@/components/ui/table/TableHead.vue'
import TableHeader from '@/components/ui/table/TableHeader.vue'
import TableRow from '@/components/ui/table/TableRow.vue'
import Tabs from '@/components/ui/tabs/Tabs.vue'
import TabsList from '@/components/ui/tabs/TabsList.vue'
import TabsTrigger from '@/components/ui/tabs/TabsTrigger.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSettingsNavigation } from '@/platform/settings/composables/useSettingsNavigation'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
import { buildSupportUrl } from '@/platform/support/config'
import { useAutoPageSize } from '@/platform/workspace/composables/useAutoPageSize'
import { requestMembersSort } from '@/platform/workspace/composables/useMembersPanel'
import { useResubscribe } from '@/platform/workspace/composables/useResubscribe'
import { useTeamPlan } from '@/platform/workspace/composables/useTeamPlan'
import { useWorkspaceOverview } from '@/platform/workspace/composables/useWorkspaceOverview'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useDialogService } from '@/services/dialogService'
const { t } = useI18n()
// Plan lifecycle actions are for the workspace creator (Owner) only; Admins and
// Members don't see Change plan or the overflow menu.
const {
isOriginalOwner,
isActiveSubscription,
isTeamPlanCancelled,
permissions
} = useWorkspaceUI()
// Members can't manage or view billing details — only the credit balance. Gates
// the plan price, payment/plan actions, snapshot, next invoice, and auto-reload.
const canManageBilling = computed(() => permissions.value.canManageSubscription)
const {
isFreeTier,
isPaused,
subscription,
manageSubscription,
showSubscriptionDialog
} = useBillingContext()
const { showCancelSubscriptionDialog } = useDialogService()
const { showPricingTable } = useSubscriptionDialog()
function handleChangePlan() {
if (isFreeTier.value) showPricingTable({ reason: 'settings_billing_panel' })
else showSubscriptionDialog({ reason: 'settings_billing_panel' })
}
// A team (or enterprise) workspace whose plan has lapsed shows the reactivation
// state in place of the live plan header, snapshot, and auto-reload.
const { hasLapsedTeamPlan: isInactive } = useTeamPlan()
const { isResubscribing, handleResubscribe } = useResubscribe()
const { buildDocsUrl, docsPaths } = useExternalLink()
const learnMoreUrl = buildDocsUrl('/get_started/cloud', {
includeLocale: true
})
const partnerNodesPricingUrl = buildDocsUrl(docsPaths.partnerNodesPricing, {
includeLocale: true
})
const { userEmail, resolvedUserInfo } = useCurrentUser()
function openSupport() {
const url = buildSupportUrl({
userEmail: userEmail.value,
userId: resolvedUserInfo.value?.id
})
window.open(url, '_blank', 'noopener,noreferrer')
}
const canCancelPlan = computed(
() =>
isOriginalOwner.value &&
isActiveSubscription.value &&
!isTeamPlanCancelled.value &&
!isFreeTier.value
)
const planMenuEntries = computed<MenuItem[]>(() =>
canCancelPlan.value
? [
{
label: t('subscription.cancelPlan'),
command: () =>
void showCancelSubscriptionDialog(
subscription.value?.endDate ?? undefined
)
}
]
: []
)
const { plan, topSpenders, recentActivity } = useWorkspaceOverview()
// Enterprise workspaces read "enterprise" in the lapsed copy, not "team".
const inactiveTitle = computed(() =>
plan.value.name === 'Enterprise'
? t('workspacePanel.overview.inactive.titleEnterprise')
: t('workspacePanel.overview.inactive.title')
)
const inactiveSubtitle = computed(() =>
plan.value.name === 'Enterprise'
? t('workspacePanel.overview.inactive.subtitleEnterprise')
: t('workspacePanel.overview.inactive.subtitle')
)
const { navigateToPanel } = useSettingsNavigation()
// The credits tile dictates the row height; the snapshot tile fits as many
// user rows as that height allows, keeping "See more" pinned to the bottom.
const snapshotContainer = ref<HTMLElement | null>(null)
const { pageSize: visibleSnapshotRows } = useAutoPageSize(snapshotContainer, 1)
const snapshotView = ref('top')
const snapshotRows = computed(() =>
(snapshotView.value === 'top'
? topSpenders.value
: recentActivity.value
).slice(0, visibleSnapshotRows.value)
)
// Each tab gets its own empty state: a top-spenders leaderboard of all-zeros
// (a fresh billing cycle) is meaningless, and recent activity can simply have
// no events yet. Returns the message to show, or null when the table has rows.
const snapshotEmptyMessage = computed(() => {
if (snapshotView.value === 'top') {
return topSpenders.value.some((row) => row.credits > 0)
? null
: t('workspacePanel.overview.snapshot.empty.topSpenders')
}
return recentActivity.value.length === 0
? t('workspacePanel.overview.snapshot.empty.recentActivity')
: null
})
// Both snapshot tabs deep-link to the Members panel (sorted by spend or
// recency) until the Activity tab exists.
function handleSeeMore() {
requestMembersSort(
snapshotView.value === 'recent' ? 'lastActivity' : 'credits'
)
navigateToPanel('workspace-members')
}
</script>

View File

@@ -1,129 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
import WorkspacePanelContent from './WorkspacePanelContent.vue'
const mockFetchMembers = vi.fn()
const mockFetchPendingInvites = vi.fn()
const { mockMembers, mockWorkspaceType } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
mockMembers: ref<WorkspaceMember[]>([]),
mockWorkspaceType: ref<'personal' | 'team'>('team')
}
})
vi.mock('pinia', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
storeToRefs: (store: Record<string, unknown>) => store
}
})
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
useTeamWorkspaceStore: () => ({
workspaceName: ref('Acme Team'),
members: mockMembers,
fetchMembers: mockFetchMembers,
fetchPendingInvites: mockFetchPendingInvites
})
}
})
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
useWorkspaceUI: () => ({
workspaceType: mockWorkspaceType,
workspaceRole: ref('owner')
})
}
})
vi.mock(
'@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue',
() => ({
default: { name: 'SubscriptionPanelContentWorkspace', template: '<div />' }
})
)
vi.mock(
'@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue',
() => ({
default: { name: 'MembersPanelContent', template: '<div />' }
})
)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
function createMember(id: string): WorkspaceMember {
return {
id,
name: `Member ${id}`,
email: `member${id}@example.com`,
joinDate: new Date('2025-01-15'),
role: 'member',
isOriginalOwner: false
}
}
function renderComponent() {
return render(WorkspacePanelContent, {
global: {
plugins: [i18n],
stubs: { WorkspaceProfilePic: true }
}
})
}
describe('WorkspacePanelContent members tab label', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMembers.value = []
mockWorkspaceType.value = 'team'
})
it('shows the counted label for team workspaces with multiple members', () => {
mockMembers.value = [createMember('1'), createMember('2')]
renderComponent()
expect(screen.getByText(/workspacePanel\.tabs\.membersCount/)).toBeTruthy()
})
it('drops the count when the owner is the only member', () => {
mockMembers.value = [createMember('1')]
renderComponent()
expect(screen.getByText('workspacePanel.members.header')).toBeTruthy()
expect(screen.queryByText(/workspacePanel\.tabs\.membersCount/)).toBeNull()
})
it('shows the plain Members label for personal workspaces', () => {
mockWorkspaceType.value = 'personal'
mockMembers.value = [createMember('1'), createMember('2')]
renderComponent()
expect(screen.getByText('workspacePanel.members.header')).toBeTruthy()
expect(screen.queryByText(/workspacePanel\.tabs\.membersCount/)).toBeNull()
})
it('fetches members and pending invites on mount', () => {
renderComponent()
expect(mockFetchMembers).toHaveBeenCalled()
expect(mockFetchPendingInvites).toHaveBeenCalled()
})
})

View File

@@ -1,95 +0,0 @@
<template>
<div class="flex size-full flex-col">
<header class="mb-6 flex items-center gap-4">
<WorkspaceProfilePic
class="size-12 text-3xl!"
:workspace-name="workspaceName"
/>
<h1 class="text-3xl font-semibold text-base-foreground">
{{ workspaceName }}
</h1>
</header>
<TabsRoot v-model="activeTab">
<TabsList class="flex items-center gap-2 pb-1">
<TabsTrigger
value="plan"
:class="
cn(
tabTriggerBase,
activeTab === 'plan' ? tabTriggerActive : tabTriggerInactive
)
"
>
{{ $t('workspacePanel.tabs.planCredits') }}
</TabsTrigger>
<TabsTrigger
value="members"
:class="
cn(
tabTriggerBase,
activeTab === 'members' ? tabTriggerActive : tabTriggerInactive
)
"
>
{{
showMembersTabCount
? $t('workspacePanel.tabs.membersCount', {
count: members.length
})
: $t('workspacePanel.members.header')
}}
</TabsTrigger>
</TabsList>
<TabsContent value="plan" class="mt-4">
<SubscriptionPanelContentWorkspace />
</TabsContent>
<TabsContent value="members" class="mt-4">
<MembersPanelContent :key="workspaceRole" />
</TabsContent>
</TabsRoot>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@comfyorg/tailwind-utils'
const tabTriggerBase =
'flex items-center justify-center shrink-0 px-2.5 py-2 text-sm rounded-lg cursor-pointer transition-all duration-200 outline-hidden border-none'
const tabTriggerActive =
'bg-interface-menu-component-surface-hovered text-text-primary font-bold'
const tabTriggerInactive =
'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface'
const { defaultTab = 'plan' } = defineProps<{
defaultTab?: string
}>()
const workspaceStore = useTeamWorkspaceStore()
const { workspaceName, members } = storeToRefs(workspaceStore)
const { fetchMembers, fetchPendingInvites } = workspaceStore
const { workspaceType, workspaceRole } = useWorkspaceUI()
const isPersonalWorkspace = computed(() => workspaceType.value === 'personal')
const activeTab = ref(defaultTab)
// Per design, the tab counts members only when there is more than the owner
const showMembersTabCount = computed(
() => !isPersonalWorkspace.value && members.value.length > 1
)
onMounted(() => {
fetchMembers()
fetchPendingInvites()
})
</script>

View File

@@ -30,6 +30,13 @@ type SortField =
| 'expiryDate'
type SortDirection = 'asc' | 'desc'
// One-shot sort applied the next time the Members panel mounts, so other panels
// can deep-link in with a preset ordering (e.g. Overview "Top spenders").
const pendingSort = ref<SortField | null>(null)
export function requestMembersSort(field: 'credits' | 'lastActivity') {
pendingSort.value = field
}
export function sortMembers(
members: WorkspaceMember[],
currentUserEmail: string | null,
@@ -201,8 +208,9 @@ export function useMembersPanel() {
const searchQuery = ref('')
const activeView = ref<ActiveView>('active')
const sortField = ref<SortField>('inviteDate')
const sortField = ref<SortField>(pendingSort.value ?? 'inviteDate')
const sortDirection = ref<SortDirection>('desc')
pendingSort.value = null
function roleMenuItem(
member: WorkspaceMember,

View File

@@ -0,0 +1,9 @@
import { computed } from 'vue'
// TODO(billing-history endpoint): the workspace billing API does not expose
// the upcoming invoice amount yet; the Invoices banner stays hidden until it
// does. Invoice history itself lives in the Stripe portal by design.
export function useWorkspaceInvoices() {
const nextInvoiceCents = computed<number | null>(() => null)
return { nextInvoiceCents }
}

View File

@@ -0,0 +1,87 @@
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { userBadgeColor } from '@/platform/workspace/utils/badgeColor'
import { formatRelativeTime } from '@/platform/workspace/utils/relativeTime'
export interface SnapshotRow {
userName: string
color: string
lastActivity: string
credits: number
}
// Upper bound on the snapshot pool; the tile shows as many of these as its
// height allows (see visibleSnapshotRows in WorkspaceOverviewContent).
const SNAPSHOT_SIZE = 6
export function useWorkspaceOverview() {
const { t, d } = useI18n()
const store = useTeamWorkspaceStore()
const { members } = storeToRefs(store)
const { subscription, renewalDate } = useBillingContext()
const { allowanceTotalCredits } = useSubscriptionCredits()
const renewalLabel = computed(() => {
const raw = renewalDate.value
if (!raw) return '—'
return d(new Date(raw), { month: 'short', day: 'numeric', year: 'numeric' })
})
// The enterprise tier relabels the same team layout. 'ENTERPRISE' is a wire
// tier not yet in the generated SubscriptionTier union, hence the cast.
const plan = computed(() => ({
name:
(subscription.value?.tier as string | null) === 'ENTERPRISE'
? 'Enterprise'
: 'Team',
monthlyCredits: allowanceTotalCredits.value ?? 0,
renewalLabel: renewalLabel.value
}))
function activityLabel(member: WorkspaceMember): string {
if (!member.lastActivity) return '—'
return formatRelativeTime(member.lastActivity, new Date(), {
justNow: t('workspacePanel.members.activity.justNow'),
minutesAgo: (n) => t('workspacePanel.members.activity.minutesAgo', { n }),
hoursAgo: (n) => t('workspacePanel.members.activity.hoursAgo', { n }),
daysAgo: (n) => t('workspacePanel.members.activity.daysAgo', n)
})
}
function toRow(member: WorkspaceMember): SnapshotRow {
return {
userName: member.name,
color: userBadgeColor(member.name || member.email),
lastActivity: activityLabel(member),
credits: member.creditsUsedThisMonth ?? 0
}
}
const topSpenders = computed(() =>
[...members.value]
.sort(
(a, b) => (b.creditsUsedThisMonth ?? 0) - (a.creditsUsedThisMonth ?? 0)
)
.slice(0, SNAPSHOT_SIZE)
.map(toRow)
)
const recentActivity = computed(() =>
[...members.value]
.filter((member) => member.lastActivity)
.sort(
(a, b) =>
(b.lastActivity?.getTime() ?? 0) - (a.lastActivity?.getTime() ?? 0)
)
.slice(0, SNAPSHOT_SIZE)
.map(toRow)
)
return { plan, topSpenders, recentActivity }
}