Compare commits

...

22 Commits

Author SHA1 Message Date
huang47
3562a66d1f feat: block disabled partner nodes from execution 2026-07-14 13:43:15 -07:00
huang47
68bea387e1 feat: surface disabled nodes as workflow errors 2026-07-14 13:43:15 -07:00
huang47
5322dd90a2 feat: hide disabled partner nodes from discovery 2026-07-14 13:43:15 -07:00
huang47
5c92d4b521 fix: harden partner node policy editor 2026-07-14 13:43:15 -07:00
huang47
f15710a43f feat: add gated partner node policy store 2026-07-14 13:43:15 -07:00
huang47
ac126f3c59 feat: add partner node policy API seam 2026-07-14 12:48:50 -07:00
huang47
957aafb9e8 fix: address workspace settings review feedback 2026-07-14 12:48:20 -07:00
huang47
3dfc5b6b52 fix: address follow-up review feedback 2026-07-14 12:12:11 -07:00
huang47
49130c5767 test: cover billing status lifecycle 2026-07-14 12:12:11 -07:00
huang47
8f5a9cc6bb fix: address workspace review feedback 2026-07-14 12:12:11 -07:00
huang47
d82d9f30d2 fix: preserve hidden partner node selections 2026-07-14 12:12:10 -07:00
huang47
300901c793 test: cover confirmed rollback ordering 2026-07-14 12:12:10 -07:00
huang47
ab646e7401 fix: address allowlist review feedback 2026-07-14 12:12:10 -07:00
huang47
58048b9f9f test: update mobile settings screenshot 2026-07-14 12:12:10 -07:00
huang47
9553b7a579 fix: prevent stale allowlist rollbacks 2026-07-14 12:12:10 -07:00
huang47
563eda5862 fix: harden allowlist governance controls 2026-07-14 12:12:10 -07:00
huang47
1c78ea091f test: cover allowlist governance edge cases 2026-07-14 12:12:10 -07:00
comfydesigner
7d6aa42019 feat: workspace Allowlist — partner nodes + models governance
Adds the Allowlist settings entry (owners/admins only) with two
sub-tabs sharing one search input. Partner nodes: the workspace's
partner-node list with per-row toggles, page-scoped select-all with
cross-page selection and a bulk toggle, Enable/Disable all acting on
the filtered set, and an auto-enable-new default, backed by the
partner-nodes workspace API with optimistic updates. Models: the same
governance surface for the models catalog — Model / Type / Last
modified with middle-truncated display names (HF names keep their
filename tail) — rendering its empty state until the model-allowlist
endpoint exists; the pagination and reset-to-page-1 search semantics
map onto the eventual limit/offset API, and Enable/Disable all needs a
server-side bulk-by-query op.
2026-07-14 12:12:10 -07:00
comfydesigner
6bddc2f9ae feat: billing lifecycle states across workspace settings
One status-banner slot (BillingStatusBanner) above every workspace
settings surface, rendering by priority: subscription paused / payment
declined / out of credits (amber triangle, action needed) and the plan-
ending notice (muted circle, secondary Reactivate — cancellation was
intentional). Adds the paused subscription status, the Credits tab
header with plan-aware inactive copy (team vs enterprise) wired to
resubscribe, and a member snapshot tile (top spenders / recent
activity from the members store, sized to the dialog via
useAutoPageSize; renders its empty states until the API carries
per-member usage).

CreditsTile: 24px total, Monthly/Yearly cycle bar (annual grants are
front-loaded), inverted Add credits when fully out, dims when paused,
and a frozen mode for lapsed plans. Credit iconography moves to
lucide-coins across credit surfaces.
2026-07-14 12:12:10 -07:00
comfydesigner
65c0d9fe54 feat: workspace Members settings panel + identity editing
Restructures workspace member management into its own settings entry:
a Members sidebar panel (table rows with role menu, last-activity and
credits-used columns rendering em-dash/0 until the API carries the
fields, pending-invite rows, Owner/Admin role labels), the workspace
settings header with inline rename (double-click or menu, save-on-blur,
Escape cancels, 30-char cap) and hover image-edit affordance, and the
member-limit flow (count-free copy, Invite enabled at cap surfacing a
request-seats dialog wired to the team-plan request form; seat cap 50
counting pending invites).

The existing combined Workspace panel stays registered so plan and
credit management remains reachable; a follow-up swaps it to the
dedicated Plan & Credits panel. Table primitives (nowrap cells,
divider rows) land here with their first consumer.
2026-07-14 12:12:10 -07:00
comfydesigner
8fdbd1161f feat: make dropdown menu modality configurable 2026-07-14 12:12:10 -07:00
comfydesigner
9caaa9e98b refactor: extract shared selection bar 2026-07-14 12:12:09 -07:00
113 changed files with 5596 additions and 1065 deletions

View File

@@ -354,7 +354,9 @@ export class AssetsSidebarTab extends SidebarTab {
)
this.selectionFooter = page.getByTestId('assets-selection-bar')
this.selectionCountButton = page.getByText(/\d+ selected/)
this.deselectAllButton = page.getByTestId('assets-deselect-selected')
this.deselectAllButton = page.getByRole('button', {
name: 'Deselect all'
})
this.deleteSelectedButton = page.getByTestId('assets-delete-selected')
this.downloadSelectedButton = page.getByTestId('assets-download-selected')
this.backToAssetsButton = page.getByText('Back to all assets')

View File

@@ -205,30 +205,27 @@ test.describe('Credits tile (Plan & Credits)', { tag: '@cloud' }, () => {
await expect(content.getByText('Total credits')).toBeVisible()
await expect(content.getByText('12,660')).toBeVisible()
// Monthly usage bar header + used / left-of-total labels.
await expect(content.getByText('Monthly', { exact: true })).toBeVisible()
await expect(content.getByText(/Refills Feb/)).toBeVisible()
await expect(content.getByText('10,550 used')).toBeVisible()
await expect(content.getByText('10,550 left of 21,100')).toBeVisible()
await expect(content.getByText('50% used')).toBeVisible()
// Additional credits row + subtitle.
await expect(content.getByText('Additional credits')).toBeVisible()
await expect(content.getByText('2,110')).toBeVisible()
await expect(content.getByText('Used after monthly runs out')).toBeVisible()
await expect(
content.getByText('Used after plan credits run out')
).toBeVisible()
// Permission-gated add-credits action (personal owner can top up).
await expect(
content.getByRole('button', { name: 'Add credits' })
).toBeVisible()
// Narrow container (DES-247 responsive variants): drop the used/remaining
// labels and the breakdown subtitle, compact the monthly summary numbers.
await page.setViewportSize({ width: 360, height: 800 })
await expect(content.getByText('10,550 used')).toBeHidden()
await expect(content.getByText('remaining', { exact: true })).toBeHidden()
await expect(content.getByText('Used after monthly runs out')).toBeHidden()
await expect(content.getByText('10,550 left of 21,100')).toBeHidden()
await expect(content.getByText('11K left of 21K')).toBeVisible()
await expect(
content.getByText('Used after plan credits run out')
).toBeHidden()
await expect(content.getByText('50% used')).toBeVisible()
})
test('renders the depleted-credit empty states', async ({ page }) => {
@@ -240,27 +237,17 @@ test.describe('Credits tile (Plan & Credits)', { tag: '@cloud' }, () => {
const content = await openPlanAndCredits(page)
// 0-monthly state: depletion notice + IN USE badge on additional credits.
await expect(
content.getByText('Monthly credits are used up. Refills Feb 20')
).toBeVisible()
await expect(
content.getByText("You're now spending additional credits.")
).toBeVisible()
await expect(content.getByText('100% used')).toBeVisible()
await expect(content.getByText('In use')).toBeVisible()
await expect(content.getByText('0 left of 21,100')).toBeVisible()
await expect(
content.getByText('2,110', { exact: true }).last()
).toBeVisible()
// Drain the remaining additional credits and refresh the tile: the
// out-of-credits notice takes over and the badge drops.
await mockBalance(page, { amount: 0, monthly: 0, prepaid: 0 })
await content.getByRole('button', { name: 'Refresh credits' }).click()
await expect(
content.getByText("You're out of credits. Credits refill Feb 20")
).toBeVisible()
await expect(
content.getByText('Add more credits to continue generating.')
).toBeVisible()
await expect(content.getByText('0', { exact: true }).first()).toBeVisible()
await expect(content.getByText('100% used')).toBeVisible()
await expect(content.getByText('In use')).toBeHidden()
await expect(
content.getByRole('button', { name: 'Add credits' })

View File

@@ -22,7 +22,7 @@ import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMo
* The viewer is a promoted owner (not the workspace creator), so the spec can
* distinguish the creator guard from the self guard: the creator row and the
* viewer's own row hide the row menu, every other row exposes
* "Change role " (Owner / Member) plus "Remove member". Promoting a member
* "Change role " (Admin / Member) plus "Remove member". Promoting a member
* sends PATCH /api/workspace/members/:id {role}, flips the Role column,
* re-sorts the row under the creator, and the promoted owner stays demotable.
*/
@@ -44,13 +44,15 @@ async function openMembersTab(page: Page): Promise<Locator> {
const content = dialog.getByRole('main')
await content.getByRole('tab', { name: /Members/ }).click()
await expect(content.getByText('4 of 30 members')).toBeVisible()
await expect(
content.getByRole('tabpanel', { name: 'Members (4)' }).getByRole('table')
).toBeVisible()
return content
}
function memberRow(content: Locator, email: string): Locator {
return content
.locator('div.grid')
.getByRole('row')
.filter({ has: content.page().getByText(email, { exact: true }) })
}
@@ -66,7 +68,7 @@ async function openChangeRoleSubmenu(page: Page) {
await expect(trigger).toBeVisible()
await trigger.press('ArrowRight')
await expect(
page.getByRole('menuitemradio', { name: 'Owner', exact: true })
page.getByRole('menuitemradio', { name: 'Admin', exact: true })
).toBeVisible()
}
@@ -111,14 +113,14 @@ test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
page.getByRole('menuitemradio', { name: 'Member', exact: true })
).toHaveAttribute('aria-checked', 'true')
await expect(
page.getByRole('menuitemradio', { name: 'Owner', exact: true })
page.getByRole('menuitemradio', { name: 'Admin', exact: true })
).toHaveAttribute('aria-checked', 'false')
await page
.getByRole('menuitemradio', { name: 'Member', exact: true })
.click()
.press('Enter')
await expect(page.getByRole('heading', { name: /an owner\?/ })).toHaveCount(
await expect(page.getByRole('heading', { name: /an admin\?/ })).toHaveCount(
0
)
expect(state.patches).toHaveLength(0)
@@ -134,11 +136,11 @@ test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
await page
.getByRole('menuitemradio', { name: 'Owner', exact: true })
.click()
.getByRole('menuitemradio', { name: 'Admin', exact: true })
.press('Enter')
await expect(
page.getByRole('heading', { name: 'Make Jane an owner?' })
page.getByRole('heading', { name: 'Make Jane an admin?' })
).toBeVisible()
await expect(page.getByText("They'll be able to:")).toBeVisible()
await expect(page.getByText('Add additional credits')).toBeVisible()
@@ -147,7 +149,7 @@ test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
).toBeVisible()
await expect(
page.getByText(
'Promote and demote other owners (except the workspace creator).'
'Promote and demote other admins (except the workspace creator).'
)
).toBeVisible()
@@ -177,12 +179,12 @@ test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
await page
.getByRole('menuitemradio', { name: 'Owner', exact: true })
.click()
await page.getByRole('button', { name: 'Make owner' }).click()
.getByRole('menuitemradio', { name: 'Admin', exact: true })
.press('Enter')
await page.getByRole('button', { name: 'Make admin' }).click()
await expect(page.getByText('Role updated')).toBeVisible()
await expect(janeRow.getByText('Owner', { exact: true })).toBeVisible()
await expect(janeRow.getByText('Admin', { exact: true })).toBeVisible()
await expect(emails).toHaveText([
CREATOR.email,
VIEWER.email,
@@ -211,13 +213,13 @@ test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
const content = await openMembersTab(page)
const janeRow = memberRow(content, MEMBER_JANE.email)
await expect(janeRow.getByText('Owner', { exact: true })).toBeVisible()
await expect(janeRow.getByText('Admin', { exact: true })).toBeVisible()
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
await page
.getByRole('menuitemradio', { name: 'Member', exact: true })
.click()
.press('Enter')
await expect(
page.getByRole('heading', { name: 'Demote Jane to member?' })
).toBeVisible()
@@ -249,14 +251,14 @@ test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
await page
.getByRole('menuitemradio', { name: 'Owner', exact: true })
.click()
await page.getByRole('button', { name: 'Make owner' }).click()
.getByRole('menuitemradio', { name: 'Admin', exact: true })
.press('Enter')
await page.getByRole('button', { name: 'Make admin' }).click()
// US10 — error toast, dialog stays open, role unchanged.
await expect(page.getByText('Failed to update role')).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Make Jane an owner?' })
page.getByRole('heading', { name: 'Make Jane an admin?' })
).toBeVisible()
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -7,12 +7,14 @@ import type {
JobListItem,
JobStatus
} from '@/platform/remote/comfyui/jobs/jobTypes'
import { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
import { useCommandStore } from '@/stores/commandStore'
import {
TaskItemImpl,
useQueueSettingsStore,
useQueueStore
} from '@/stores/queueStore'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
@@ -62,7 +64,8 @@ const i18n = createI18n({
stopRunInstantTooltip: 'Stop running',
runWorkflow: 'Run workflow',
runWorkflowFront: 'Run workflow front',
runWorkflowDisabled: 'Run workflow disabled'
runWorkflowDisabled: 'Run workflow disabled',
runWorkflowDisabledNodes: 'Run workflow disabled nodes'
}
}
}
@@ -182,4 +185,32 @@ describe('ComfyQueueButton', () => {
}
})
})
it('keeps instant mode idle while dispatching a disabled-node queue command', async () => {
const { user } = renderQueueButton()
const queueSettingsStore = useQueueSettingsStore()
const commandStore = useCommandStore()
const disabledPartnerNodesStore = useDisabledPartnerNodesStore()
disabledPartnerNodesStore.offenders = [
{
nodeId: createNodeExecutionId([1]),
displayName: 'Blocked Partner Node'
}
]
queueSettingsStore.mode = 'instant-idle'
await nextTick()
await user.click(screen.getByTestId('queue-button'))
await nextTick()
expect(queueSettingsStore.mode).toBe('instant-idle')
expect(disabledPartnerNodesStore.scanGraph).toHaveBeenCalledOnce()
expect(commandStore.execute).toHaveBeenCalledWith('Comfy.QueuePrompt', {
metadata: {
subscribe_to_run: false,
trigger_source: 'button'
}
})
})
})

View File

@@ -82,6 +82,7 @@ import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
isInstantMode,
@@ -99,6 +100,10 @@ const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>
graphHasMissingNodes(app.rootGraph, nodeDefStore.nodeDefsByName)
)
const disabledPartnerNodesStore = useDisabledPartnerNodesStore()
const hasDisabledNodes = computed(
() => disabledPartnerNodesStore.offenders.length > 0
)
const { t } = useI18n()
type QueueModeMenuKey = 'disabled' | 'change' | 'instant-idle'
@@ -190,7 +195,7 @@ const iconClass = computed(() => {
if (isStopInstantAction.value) {
return 'icon-[lucide--square]'
}
if (hasMissingNodes.value) {
if (hasMissingNodes.value || hasDisabledNodes.value) {
return 'icon-[lucide--triangle-alert]'
}
if (workspaceStore.shiftDown) {
@@ -215,6 +220,9 @@ const queueButtonTooltip = computed(() => {
if (hasMissingNodes.value) {
return t('menu.runWorkflowDisabled')
}
if (hasDisabledNodes.value) {
return t('menu.runWorkflowDisabledNodes')
}
if (workspaceStore.shiftDown) {
return t('menu.runWorkflowFront')
}
@@ -233,7 +241,8 @@ const queuePrompt = async (e: Event) => {
? 'Comfy.QueuePromptFront'
: 'Comfy.QueuePrompt'
if (isInstantMode(queueMode.value)) {
disabledPartnerNodesStore.scanGraph()
if (!hasDisabledNodes.value && isInstantMode(queueMode.value)) {
queueMode.value = 'instant-running'
}

View File

@@ -19,7 +19,11 @@ defineOptions({
inheritAttrs: false
})
const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
const {
itemClass: itemProp,
contentClass: contentProp,
modal = true
} = defineProps<{
entries?: MenuItem[]
icon?: string
to?: string | HTMLElement
@@ -27,6 +31,7 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
contentClass?: string
buttonSize?: ButtonVariants['size']
buttonClass?: string
modal?: boolean
}>()
const itemClass = computed(() =>
@@ -48,7 +53,7 @@ const contentStyle = useModalLiftedZIndex(open)
</script>
<template>
<DropdownMenuRoot v-model:open="open">
<DropdownMenuRoot v-model:open="open" :modal>
<DropdownMenuTrigger as-child>
<slot name="button">
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">

View File

@@ -0,0 +1,43 @@
<template>
<div class="relative mx-2">
<div
v-bind="$attrs"
class="absolute bottom-6 left-1/2 z-40 flex w-full max-w-78 -translate-x-1/2 items-center gap-2 rounded-lg bg-base-foreground p-2 text-base-background shadow-interface"
>
<Button
v-tooltip.top="{ value: deselectLabel, showDelay: 300 }"
variant="inverted"
size="icon-lg"
type="button"
:aria-label="deselectLabel"
class="rounded-lg hover:bg-base-background/10"
@click="emit('deselect')"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<span class="pr-6 text-sm font-bold whitespace-nowrap tabular-nums">
{{ label }}
</span>
<div class="ml-auto flex shrink-0 items-center gap-1">
<slot />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
defineOptions({ inheritAttrs: false })
defineProps<{
/** The "N selected" text; the caller formats it (pluralization, wording). */
label: string
/** Accessible label + tooltip for the deselect button. */
deselectLabel: string
}>()
const emit = defineEmits<{
deselect: []
}>()
</script>

View File

@@ -14,7 +14,7 @@
class="p-1 text-amber-400"
>
<template #icon>
<i class="icon-[lucide--component]" />
<i class="icon-[lucide--coins]" />
</template>
</Tag>
<div :class="textClass">

View File

@@ -404,6 +404,18 @@ describe('shouldPreventRekaDismiss', () => {
expect(event.defaultPrevented).toBe(false)
})
it('allows dismiss when target is an outside popup trigger', () => {
const trigger = document.createElement('button')
trigger.setAttribute('aria-haspopup', 'menu')
document.body.appendChild(trigger)
const event = makeEvent(trigger)
onRekaPointerDownOutside({ dismissableMask: undefined }, event)
expect(event.defaultPrevented).toBe(false)
trigger.remove()
})
it('prevents dismiss when the dialog is not the top-most (stacked)', () => {
// A backgrounded dialog must never dismiss on an outside pointer — the
// pointer belongs to the dialog stacked above it (e.g. Edit Keybinding

View File

@@ -86,7 +86,7 @@
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
<i class="icon-[lucide--coins] size-4 shrink-0 text-gold-500" />
</template>
</FormattedNumberStepper>
</div>
@@ -98,7 +98,7 @@
v-if="isBelowMin"
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-red-500"
>
<i class="icon-[lucide--component] size-4" />
<i class="icon-[lucide--coins] size-4" />
{{
$t('credits.topUp.minRequired', {
credits: formatNumber(usdToCredits(MIN_AMOUNT))
@@ -109,7 +109,7 @@
v-if="showCeilingWarning"
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-gold-500"
>
<i class="icon-[lucide--component] size-4" />
<i class="icon-[lucide--coins] size-4" />
{{
$t('credits.topUp.maxAllowed', {
credits: formatNumber(usdToCredits(MAX_AMOUNT))

View File

@@ -7,7 +7,7 @@
)
"
>
<i class="icon-[lucide--component] h-full bg-amber-400" />
<i class="icon-[lucide--coins] h-full bg-amber-400" />
<span class="truncate" v-text="text" />
</span>
<span

View File

@@ -38,6 +38,7 @@ import {
} from './shared'
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
import TabErrors from './errors/TabErrors.vue'
import { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
@@ -157,14 +158,33 @@ const hasMissingMediaSelected = computed(
)
)
const disabledPartnerNodesStore = useDisabledPartnerNodesStore()
const activeDisabledGraphNodeIds = computed<Set<string>>(() => {
if (!app.isGraphReady) return new Set()
return getActiveGraphNodeIds(
app.rootGraph,
canvasStore.currentGraph ?? app.rootGraph,
disabledPartnerNodesStore.disabledAncestorExecutionIds
)
})
const hasDisabledNodeSelected = computed(
() =>
hasSelection.value &&
selectedNodes.value.some((node) =>
activeDisabledGraphNodeIds.value.has(String(node.id))
)
)
const hasRelevantErrors = computed(() => {
if (!hasSelection.value) return hasAnyError.value
if (!hasSelection.value)
return hasAnyError.value || disabledPartnerNodesStore.offenders.length > 0
return (
hasDirectNodeError.value ||
hasContainerInternalError.value ||
hasMissingNodeSelected.value ||
hasMissingModelSelected.value ||
hasMissingMediaSelected.value
hasMissingMediaSelected.value ||
hasDisabledNodeSelected.value
)
})

View File

@@ -309,6 +309,11 @@
:highlighted-node-ids="selectionMatchedAssetNodeIds"
@locate-node="handleLocateAssetNode"
/>
<DisabledNodesCard
v-if="group.type === 'disabled_node'"
:offenders="disabledPartnerNodesStore.offenders"
@locate-node="handleLocateAssetNode"
/>
</ErrorCardSection>
</TransitionGroup>
</div>
@@ -336,10 +341,12 @@ import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
import DisabledNodesCard from '@/platform/workspace/components/errors/DisabledNodesCard.vue'
import { isCloud } from '@/platform/distribution/types'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useErrorGroups } from './useErrorGroups'
@@ -362,6 +369,7 @@ const { copyToClipboard } = useCopyToClipboard()
const { focusNode } = useFocusNode()
const rightSidePanelStore = useRightSidePanelStore()
const missingModelStore = useMissingModelStore()
const disabledPartnerNodesStore = useDisabledPartnerNodesStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
useManagerState()
const { missingNodePacks } = useMissingNodes()

View File

@@ -45,3 +45,6 @@ export type ErrorGroup =
| (ErrorGroupBase & {
type: 'missing_media'
})
| (ErrorGroupBase & {
type: 'disabled_node'
})

View File

@@ -128,6 +128,7 @@ vi.mock(
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
import { createBoundaryLinkedSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
@@ -329,6 +330,39 @@ describe('useErrorGroups', () => {
expect(groups.allErrorGroups.value).toEqual([])
})
it('includes disabled nodes in the group and node summary', async () => {
const { groups } = createErrorGroups()
const canvasStore = useCanvasStore()
vi.mocked(isLGraphNode).mockReturnValue(true)
vi.mocked(getNodeByExecutionId).mockImplementation((_graph, nodeId) =>
fromAny<LGraphNode, unknown>({ id: nodeId })
)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([{ id: '7' }])
useDisabledPartnerNodesStore().offenders = [
{
nodeId: fromAny<NodeExecutionId, unknown>('7'),
displayName: 'Selected disabled partner node'
},
{
nodeId: fromAny<NodeExecutionId, unknown>('8'),
displayName: 'Other disabled partner node'
}
]
await nextTick()
expect(groups.allErrorGroups.value).toEqual([
expect.objectContaining({ type: 'disabled_node', count: 2 })
])
expect(groups.errorNodeCount.value).toBe(2)
expect(groups.selectionMatchedGroupKeys.value).toEqual(
new Set(['disabled_node'])
)
expect(groups.selectionErrorCount.value).toBe(1)
})
it('includes missing_node group when missing nodes exist', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()

View File

@@ -5,6 +5,7 @@ import type { IFuseOptions } from 'fuse.js'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
@@ -20,7 +21,7 @@ import {
} from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { st } from '@/i18n'
import { st, t } from '@/i18n'
import type { MissingNodeType } from '@/types/comfy'
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
import { shouldRenderExecutionItemList } from './executionItemList'
@@ -236,6 +237,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
const missingNodesStore = useMissingNodesErrorStore()
const missingModelStore = useMissingModelStore()
const missingMediaStore = useMissingMediaStore()
const disabledPartnerNodesStore = useDisabledPartnerNodesStore()
const canvasStore = useCanvasStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
const collapseState = reactive<Record<string, boolean>>({})
@@ -647,6 +649,31 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
return groups.sort((a, b) => a.priority - b.priority)
}
const filteredDisabledNodes = computed(() => {
const all = disabledPartnerNodesStore.offenders
if (!selectedNodeInfo.value.nodeIds) return all
return all.filter((offender) => isAssetErrorInSelection(offender.nodeId))
})
function buildDisabledNodeGroups(
offenders: typeof disabledPartnerNodesStore.offenders
): ErrorGroup[] {
if (!offenders.length) return []
return [
{
type: 'disabled_node' as const,
groupKey: 'disabled_node',
count: offenders.length,
priority: 0,
displayTitle: t('rightSidePanel.disabledNodes.title', offenders.length),
displayMessage: t(
'rightSidePanel.disabledNodes.message',
offenders.length
)
}
]
}
const missingModelGroups = computed<MissingModelGroup[]>(() => {
return groupMissingModelCandidates(
missingModelStore.missingModelCandidates,
@@ -797,6 +824,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
processExecutionError(groupsMap)
return [
...buildDisabledNodeGroups(disabledPartnerNodesStore.offenders),
...buildMissingNodeGroups(),
...buildMissingModelGroups(),
...buildMissingMediaGroups(),
@@ -819,6 +847,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
processExecutionError(groupsMap, true)
return [
...buildDisabledNodeGroups(filteredDisabledNodes.value),
...buildMissingNodeGroups((nodeTypes) =>
someNodeTypeInSelection(nodeTypes, selectionMatchedAssetNodeIds.value)
),
@@ -886,7 +915,14 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
.flatMap((group) => (group.type === 'execution' ? group.cards : []))
.map((card) => card.nodeId)
.filter((nodeId) => nodeId != null)
return new Set([...executionNodeIds, ...assetNodeIdsWithError.value]).size
const disabledNodeIds = disabledPartnerNodesStore.offenders.map(
(offender) => offender.nodeId
)
return new Set([
...executionNodeIds,
...assetNodeIdsWithError.value,
...disabledNodeIds
]).size
})
const filteredGroups = computed<ErrorGroup[]>(() => {

View File

@@ -2,6 +2,16 @@ import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockIsNodeDefDisabled = vi.hoisted(() =>
vi.fn<(nodeDef: ComfyNodeDefImpl) => boolean>(() => false)
)
vi.mock('@/platform/workspace/stores/disabledPartnerNodesStore', () => ({
useDisabledPartnerNodesStore: () => ({
isNodeDefDisabled: mockIsNodeDefDisabled
})
}))
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
import {
createMockNodeDef,
@@ -24,6 +34,8 @@ describe('NodeSearchContent', () => {
beforeEach(() => {
setupTestPinia()
vi.restoreAllMocks()
mockIsNodeDefDisabled.mockReset()
mockIsNodeDefDisabled.mockReturnValue(false)
setViewport(DESKTOP_VIEWPORT)
const settings = useSettingStore()
settings.settingValues['Comfy.NodeLibrary.Bookmarks.V2'] = []
@@ -315,6 +327,93 @@ describe('NodeSearchContent', () => {
})
describe('search and category interaction', () => {
it('does not report disabled matches in the default empty state', async () => {
const nodeDefStore = useNodeDefStore()
nodeDefStore.updateNodeDefs([
createMockNodeDef({
name: 'BlockedPartnerNode',
display_name: 'Blocked Partner Node',
api_node: true
})
])
mockIsNodeDefDisabled.mockReturnValue(true)
nodeDefStore.registerNodeDefFilter({
id: 'test.disabled-partner-nodes',
name: 'Disabled partner nodes',
predicate: (nodeDef) => !mockIsNodeDefDisabled(nodeDef)
})
renderComponent()
expect(await screen.findByText('No Results')).toBeInTheDocument()
expect(
screen.queryByText('This node has been disabled by your team admin.')
).not.toBeInTheDocument()
})
it('explains when a query only matches an admin-disabled node', async () => {
const nodeDefStore = useNodeDefStore()
nodeDefStore.updateNodeDefs([
createMockNodeDef({
name: 'BlockedPartnerNode',
display_name: 'Blocked Partner Node',
api_node: true
})
])
mockIsNodeDefDisabled.mockImplementation(
(nodeDef: ComfyNodeDefImpl) => nodeDef.name === 'BlockedPartnerNode'
)
nodeDefStore.registerNodeDefFilter({
id: 'test.disabled-partner-nodes',
name: 'Disabled partner nodes',
predicate: (nodeDef) => !mockIsNodeDefDisabled(nodeDef)
})
const { user } = renderComponent()
await user.type(screen.getByRole('combobox'), 'Blocked Partner')
expect(
await screen.findByText(
'This node has been disabled by your team admin.'
)
).toBeInTheDocument()
expect(screen.queryByRole('option')).not.toBeInTheDocument()
})
it('does not report a disabled match outside the selected category', async () => {
const nodeDefStore = useNodeDefStore()
nodeDefStore.updateNodeDefs([
createMockNodeDef({
name: 'BlockedPartnerNode',
display_name: 'Blocked Partner Node',
category: 'loaders',
api_node: true
}),
createMockNodeDef({
name: 'SamplerNode',
display_name: 'Sampler Node',
category: 'sampling'
})
])
mockIsNodeDefDisabled.mockImplementation(
(nodeDef: ComfyNodeDefImpl) => nodeDef.name === 'BlockedPartnerNode'
)
nodeDefStore.registerNodeDefFilter({
id: 'test.disabled-partner-nodes',
name: 'Disabled partner nodes',
predicate: (nodeDef) => !mockIsNodeDefDisabled(nodeDef)
})
const { user } = renderComponent()
await user.click(await screen.findByTestId('category-sampling'))
await user.type(screen.getByRole('combobox'), 'Blocked Partner')
expect(await screen.findByText('No Results')).toBeInTheDocument()
expect(
screen.queryByText('This node has been disabled by your team admin.')
).not.toBeInTheDocument()
})
it('should search within selected category', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
@@ -757,6 +856,82 @@ describe('NodeSearchContent', () => {
})
describe('rootFilter + category + search combination', () => {
it('counts disabled nodes only in the selected category without a query', async () => {
const nodeDefStore = useNodeDefStore()
const nodeDefs = [
createMockNodeDef({
name: 'CustomSampler',
display_name: 'Custom Sampler',
category: 'sampling',
python_module: 'custom_nodes.my_extension'
}),
createMockNodeDef({
name: 'CustomLoader',
display_name: 'Custom Loader',
category: 'loaders',
python_module: 'custom_nodes.my_extension'
})
]
nodeDefStore.updateNodeDefs(nodeDefs)
const { user } = renderComponent()
await clickFilterBarButton(user, 'Extensions')
await user.click(await screen.findByTestId('category-custom/sampling'))
mockIsNodeDefDisabled.mockReturnValue(true)
nodeDefStore.registerNodeDefFilter({
id: 'test.disabled-partner-nodes',
name: 'Disabled partner nodes',
predicate: (nodeDef) => !mockIsNodeDefDisabled(nodeDef)
})
nodeDefStore.updateNodeDefs(nodeDefs)
expect(
await screen.findByText(
'This node has been disabled by your team admin.'
)
).toBeInTheDocument()
})
it('ignores disabled matches outside the selected category', async () => {
const nodeDefStore = useNodeDefStore()
const nodeDefs = [
createMockNodeDef({
name: 'CustomSampler',
display_name: 'Custom Sampler',
category: 'sampling',
python_module: 'custom_nodes.my_extension'
}),
createMockNodeDef({
name: 'CustomLoader',
display_name: 'Custom Loader',
category: 'loaders',
python_module: 'custom_nodes.my_extension'
})
]
nodeDefStore.updateNodeDefs(nodeDefs)
const { user } = renderComponent()
await clickFilterBarButton(user, 'Extensions')
await user.click(await screen.findByTestId('category-custom/sampling'))
mockIsNodeDefDisabled.mockImplementation(
(nodeDef) => nodeDef.name === 'CustomLoader'
)
nodeDefStore.registerNodeDefFilter({
id: 'test.disabled-partner-nodes',
name: 'Disabled partner nodes',
predicate: (nodeDef) => !mockIsNodeDefDisabled(nodeDef)
})
nodeDefStore.updateNodeDefs(nodeDefs)
await user.type(screen.getByRole('combobox'), 'Loader')
expect(await screen.findByText('No Results')).toBeInTheDocument()
expect(
screen.queryByText('This node has been disabled by your team admin.')
).not.toBeInTheDocument()
})
it('should intersect rootFilter, selected category, and search query', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({

View File

@@ -99,7 +99,11 @@
data-testid="no-results"
class="px-4 py-8 text-center text-muted-foreground"
>
{{ $t('g.noResults') }}
{{
disabledMatchCount > 0
? $t('nodeSearch.disabledByTeamAdmin', disabledMatchCount)
: $t('g.noResults')
}}
</div>
</div>
</div>
@@ -121,11 +125,12 @@ import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import { useDisabledNodeSearch } from '@/composables/node/useDisabledNodeSearch'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
BLUEPRINT_CATEGORY,
isCustomNode,
@@ -158,6 +163,7 @@ const { flags } = useFeatureFlags()
const nodeDefStore = useNodeDefStore()
const nodeFrequencyStore = useNodeFrequencyStore()
const nodeBookmarkStore = useNodeBookmarkStore()
const { disabledNodeDefs, disabledSearchService } = useDisabledNodeSearch()
const nodeAvailability = computed(() => {
let essential = false
@@ -216,21 +222,28 @@ const rootFilterLabel = computed(() => {
}
})
function rootFilterPredicate(
root: RootCategoryId
): (n: ComfyNodeDefImpl) => boolean {
const sourceFilter = sourceCategoryFilters[root]
if (sourceFilter) return sourceFilter
switch (root) {
case RootCategory.Favorites:
return (n) => nodeBookmarkStore.isBookmarked(n)
case RootCategory.Blueprint:
return (n) => n.category.startsWith(BLUEPRINT_CATEGORY)
case RootCategory.PartnerNodes:
return (n) => n.api_node
default:
return () => true
}
}
const rootFilteredNodeDefs = computed(() => {
if (!rootFilter.value) return nodeDefStore.visibleNodeDefs
const allNodes = nodeDefStore.visibleNodeDefs
const sourceFilter = sourceCategoryFilters[rootFilter.value]
if (sourceFilter) return allNodes.filter(sourceFilter)
switch (rootFilter.value) {
case RootCategory.Favorites:
return allNodes.filter((n) => nodeBookmarkStore.isBookmarked(n))
case RootCategory.Blueprint:
return allNodes.filter((n) => n.category.startsWith(BLUEPRINT_CATEGORY))
case RootCategory.PartnerNodes:
return allNodes.filter((n) => n.api_node)
default:
return allNodes
}
return nodeDefStore.visibleNodeDefs.filter(
rootFilterPredicate(rootFilter.value)
)
})
function onToggleFilter(
@@ -311,6 +324,15 @@ function getCategoryResults(baseNodes: ComfyNodeDefImpl[], category: string) {
})
}
function filterBySelectedCategory(baseNodes: ComfyNodeDefImpl[]) {
const category = selectedCategory.value
if (category === DEFAULT_CATEGORY) return baseNodes
const sourceFilter = sourceCategoryFilters[category]
return sourceFilter
? baseNodes.filter(sourceFilter)
: getCategoryResults(baseNodes, category)
}
const displayedResults = computed<ComfyNodeDefImpl[]>(() => {
const baseNodes = rootFilteredNodeDefs.value
const category = selectedCategory.value
@@ -330,10 +352,31 @@ const displayedResults = computed<ComfyNodeDefImpl[]>(() => {
} else {
source = baseNodes
}
return filterBySelectedCategory(source)
})
const sourceFilter = sourceCategoryFilters[category]
if (sourceFilter) return source.filter(sourceFilter)
return getCategoryResults(source, category)
const disabledMatchCount = computed(() => {
if (displayedResults.value.length > 0) return 0
if (disabledNodeDefs.value.length === 0) return 0
const inRoot = rootFilter.value
? disabledNodeDefs.value.filter(rootFilterPredicate(rootFilter.value))
: disabledNodeDefs.value
if (!searchQuery.value && filters.length === 0) {
if (!rootFilter.value && selectedCategory.value === DEFAULT_CATEGORY) {
return 0
}
return filterBySelectedCategory(inRoot).length
}
const matched = disabledSearchService.value.searchNode(
searchQuery.value,
filters,
{ limit: 64 }
)
if (!rootFilter.value) return filterBySelectedCategory(matched).length
const inRootNames = new Set(inRoot.map((n) => n.name))
return filterBySelectedCategory(
matched.filter((n) => inRootNames.has(n.name))
).length
})
const hoveredNodeDef = computed(

View File

@@ -51,7 +51,7 @@
>
<i
aria-hidden="true"
class="icon-[lucide--component] size-3 text-amber-400"
class="icon-[lucide--coins] size-3 text-amber-400"
/>
<i
aria-hidden="true"

View File

@@ -96,9 +96,11 @@
class="flex min-h-0 flex-1 items-center justify-center px-6 py-8 text-center text-sm text-muted-foreground"
>
{{
$t('sideToolbar.nodeLibraryTab.noMatchingNodes', {
query: searchQuery
})
disabledMatchCount > 0
? $t('nodeSearch.disabledByTeamAdmin', disabledMatchCount)
: $t('sideToolbar.nodeLibraryTab.noMatchingNodes', {
query: searchQuery
})
}}
</div>
<AllNodesPanel
@@ -139,6 +141,7 @@ import TabPanel from '@/components/tab/TabPanel.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useDisabledNodeSearch } from '@/composables/node/useDisabledNodeSearch'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import { usePerTabState } from '@/composables/usePerTabState'
import { ESSENTIAL_SECTIONS } from '@/constants/essentialsNodes'
@@ -277,6 +280,17 @@ const hasNoMatches = computed(
() => searchQuery.value.length > 0 && filteredNodeDefs.value.length === 0
)
const { disabledNodeDefs, disabledSearchService } = useDisabledNodeSearch()
const disabledMatchCount = computed(() => {
if (!hasNoMatches.value || disabledNodeDefs.value.length === 0) return 0
return disabledSearchService.value.searchNode(
searchQuery.value,
[],
{ limit: 64 },
{ matchWildcards: false }
).length
})
const sections = computed(() => {
return nodeOrganizationService.organizeNodesTab(activeNodes.value)
})

View File

@@ -1,5 +1,27 @@
<template>
<Toast />
<Toast group="disabled-nodes" position="top-right">
<template #message="slotProps">
<div class="flex min-w-0 flex-1 flex-col gap-2">
<span class="text-sm font-semibold">
{{ slotProps.message.summary }}
</span>
<span class="text-sm text-muted-foreground">
{{ slotProps.message.detail }}
</span>
<div class="flex justify-end">
<Button
v-if="canViewErrors"
size="sm"
variant="secondary"
@click="viewDisabledNodeDetails(slotProps.message)"
>
{{ $t('rightSidePanel.disabledNodes.viewDetails') }}
</Button>
</div>
</div>
</template>
</Toast>
<Toast group="billing-operation" position="top-right">
<template #message="slotProps">
<div class="flex items-center gap-2">
@@ -12,15 +34,28 @@
<script setup lang="ts">
import Toast from 'primevue/toast'
import type { ToastMessageOptions } from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { nextTick, watch } from 'vue'
import { computed, nextTick, watch } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
const toast = useToast()
const toastStore = useToastStore()
const settingStore = useSettingStore()
const canViewErrors = computed(
() =>
settingStore.get('Comfy.UseNewMenu') !== 'Disabled' &&
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
)
function viewDisabledNodeDetails(message: ToastMessageOptions) {
useRightSidePanelStore().openPanel('errors')
toast.remove(message)
}
watch(
() => toastStore.messagesToAdd,

View File

@@ -31,7 +31,7 @@
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i class="icon-[lucide--coins] size-4 text-amber-400" />
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
<span v-else class="text-base font-semibold text-base-foreground">{{
formattedBalance

View File

@@ -0,0 +1,38 @@
<template>
<CheckboxRoot
v-bind="forwardedProps"
v-model="checked"
:class="
cn(
'peer flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-[4px] border border-interface-stroke bg-transparent transition-colors focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:outline-none data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-white data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-white',
className
)
"
>
<CheckboxIndicator class="flex items-center justify-center">
<i
:class="
checked === 'indeterminate'
? 'icon-[lucide--minus] size-3'
: 'icon-[lucide--check] size-3'
"
/>
</CheckboxIndicator>
</CheckboxRoot>
</template>
<script setup lang="ts">
import type { CheckboxRootProps } from 'reka-ui'
import { CheckboxIndicator, CheckboxRoot, useForwardProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
type Props = Omit<CheckboxRootProps, 'defaultValue' | 'modelValue'> & {
class?: HTMLAttributes['class']
}
const { class: className, ...restProps } = defineProps<Props>()
const forwardedProps = useForwardProps(restProps)
const checked = defineModel<boolean | 'indeterminate'>({ default: false })
</script>

View File

@@ -0,0 +1,30 @@
<template>
<SwitchRoot
v-model="checked"
:disabled
:class="
cn(
'inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent px-0.5 transition-colors focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
checked ? 'bg-primary' : 'bg-interface-stroke'
)
"
>
<SwitchThumb
:class="
cn(
'pointer-events-none block size-4 rounded-full bg-white shadow-sm transition-transform',
checked ? 'translate-x-3.5' : 'translate-x-0'
)
"
/>
</SwitchRoot>
</template>
<script setup lang="ts">
import { SwitchRoot, SwitchThumb } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
const { disabled = false } = defineProps<{ disabled?: boolean }>()
const checked = defineModel<boolean>({ default: false })
</script>

View File

@@ -0,0 +1,17 @@
<template>
<div :class="cn('relative w-full overflow-auto', className)">
<table
class="w-full caption-bottom border-separate border-spacing-0 text-sm"
>
<slot />
</table>
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -0,0 +1,13 @@
<template>
<tbody :class="cn('[&_tr:last-child]:border-0', className)">
<slot />
</tbody>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -0,0 +1,13 @@
<template>
<td :class="cn('px-2 py-2.5 align-middle whitespace-nowrap', className)">
<slot />
</td>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -0,0 +1,21 @@
<template>
<th
scope="col"
:class="
cn(
'h-10 px-2 text-left align-middle text-sm font-normal whitespace-nowrap text-muted-foreground',
className
)
"
>
<slot />
</th>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -0,0 +1,15 @@
<template>
<thead
:class="cn('[&_tr]:border-b [&_tr]:border-interface-stroke/60', className)"
>
<slot />
</thead>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -0,0 +1,20 @@
<template>
<tr
:class="
cn(
'border-b border-interface-stroke/60 transition-colors hover:bg-secondary-background/50 data-[state=selected]:bg-secondary-background/50',
className
)
"
>
<slot />
</tr>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -14,7 +14,12 @@
>
<header
data-component-id="LeftPanelHeader"
class="flex h-18 w-full shrink-0 items-center-safe gap-2 pr-3 pl-6"
:class="
cn(
'flex h-18 w-full shrink-0 items-center-safe gap-2 pr-3 pl-6',
headerHeightClass
)
"
>
<slot name="leftPanelHeaderTitle" />
<Button
@@ -33,7 +38,12 @@
<div class="flex flex-col overflow-hidden bg-base-background">
<header
v-if="$slots.header"
class="flex h-18 w-full items-center justify-between gap-2 px-6"
:class="
cn(
'flex h-18 w-full items-center justify-between gap-2 px-6',
headerHeightClass
)
"
>
<div class="flex min-w-0 flex-1 gap-2">
<Button
@@ -151,20 +161,22 @@ const SIZE_CLASSES = {
} as const
type ModalSize = keyof typeof SIZE_CLASSES
type ContentPadding = 'default' | 'compact' | 'none'
type ContentPadding = 'default' | 'compact' | 'none' | 'flush'
const {
contentTitle,
rightPanelTitle,
size = 'lg',
leftPanelWidth = '14rem',
contentPadding = 'default'
contentPadding = 'default',
headerHeightClass = 'h-18'
} = defineProps<{
contentTitle: string
rightPanelTitle?: string
size?: ModalSize
leftPanelWidth?: string
contentPadding?: ContentPadding
headerHeightClass?: string
}>()
const sizeClasses = computed(() => SIZE_CLASSES[size])
@@ -204,7 +216,10 @@ const contentContainerClass = computed(() =>
cn(
'flex scrollbar-custom min-h-0 flex-1 flex-col overflow-y-auto',
contentPadding === 'default' && 'px-6 pt-0 pb-10',
contentPadding === 'compact' && 'px-6 pt-0 pb-2'
contentPadding === 'compact' && 'px-6 pt-0 pb-2',
// Keep the horizontal inset but let content run to the bottom edge (it
// clips there instead of ending above a padding gap).
contentPadding === 'flush' && 'px-6 pt-0'
)
)

View File

@@ -107,6 +107,8 @@ export interface BillingState {
export interface BillingContext extends BillingState, BillingActions {
type: ComputedRef<BillingType>
/** Subscription paused on a failed payment (`subscriptionStatus === 'paused'`). */
isPaused: ComputedRef<boolean>
/**
* True when the active team workspace is still on a pre-credit-slider
* (legacy) per-member tier plan, which keeps the old team pricing table.

View File

@@ -147,6 +147,7 @@ function useBillingContextInternal(): BillingContext {
const subscriptionStatus = computed(() =>
toValue(activeContext.value.subscriptionStatus)
)
const isPaused = computed(() => subscriptionStatus.value === 'paused')
const tier = computed(() => toValue(activeContext.value.tier))
const renewalDate = computed(() => toValue(activeContext.value.renewalDate))
@@ -301,6 +302,7 @@ function useBillingContextInternal(): BillingContext {
isLegacyTeamPlan,
billingStatus,
subscriptionStatus,
isPaused,
tier,
renewalDate,
getMaxSeats,

View File

@@ -4,6 +4,7 @@ import { computed, watch } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import type { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import type { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import type { NodeError } from '@/schemas/apiSchema'
@@ -34,7 +35,8 @@ function reconcileNodeErrorFlags(
rootGraph: LGraph,
nodeErrors: Record<string, NodeError> | null,
missingModelExecIds: Set<string>,
missingMediaExecIds: Set<string> = new Set()
missingMediaExecIds: Set<string> = new Set(),
disabledNodeExecIds: Set<string> = new Set()
): void {
// Collect nodes and slot info that should be flagged
// Includes both error-owning nodes and their ancestor containers
@@ -71,6 +73,11 @@ function reconcileNodeErrorFlags(
if (node) flaggedNodes.add(node)
}
for (const execId of disabledNodeExecIds) {
const node = getNodeByExecutionId(rootGraph, execId)
if (node) flaggedNodes.add(node)
}
forEachNode(rootGraph, (node) => {
setNodeHasErrors(node, flaggedNodes.has(node))
@@ -86,7 +93,8 @@ function reconcileNodeErrorFlags(
export function useNodeErrorFlagSync(
nodeErrors: Ref<Record<string, NodeError> | null>,
missingModelStore: ReturnType<typeof useMissingModelStore>,
missingMediaStore: ReturnType<typeof useMissingMediaStore>
missingMediaStore: ReturnType<typeof useMissingMediaStore>,
disabledPartnerNodesStore: ReturnType<typeof useDisabledPartnerNodesStore>
): () => void {
const settingStore = useSettingStore()
const showErrorsTab = computed(() =>
@@ -98,6 +106,7 @@ export function useNodeErrorFlagSync(
nodeErrors,
() => missingModelStore.missingModelNodeIds,
() => missingMediaStore.missingMediaNodeIds,
() => disabledPartnerNodesStore.disabledAncestorExecutionIds,
showErrorsTab
],
() => {
@@ -114,6 +123,9 @@ export function useNodeErrorFlagSync(
: new Set(),
showErrorsTab.value
? missingMediaStore.missingMediaAncestorExecutionIds
: new Set(),
showErrorsTab.value
? disabledPartnerNodesStore.disabledAncestorExecutionIds
: new Set()
)
},

View File

@@ -0,0 +1,20 @@
import { computed } from 'vue'
import { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
import { NodeSearchService } from '@/services/nodeSearchService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
export function useDisabledNodeSearch() {
const nodeDefStore = useNodeDefStore()
const disabledPartnerNodesStore = useDisabledPartnerNodesStore()
const disabledNodeDefs = computed(() =>
Object.values(nodeDefStore.nodeDefsByName).filter((nodeDef) =>
disabledPartnerNodesStore.isNodeDefDisabled(nodeDef)
)
)
const disabledSearchService = computed(
() => new NodeSearchService(disabledNodeDefs.value)
)
return { disabledNodeDefs, disabledSearchService }
}

View File

@@ -90,7 +90,9 @@ export function useExternalLink() {
githubFrontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
githubElectron: 'https://github.com/Comfy-Org/electron',
forum: 'https://forum.comfy.org/',
comfyOrg: 'https://www.comfy.org/'
comfyOrg: 'https://www.comfy.org/',
teamPlanRequests:
'https://comfy-org.portal.usepylon.com/forms/team-plan-requests'
}
/** Common doc paths for use with buildDocsUrl */

View File

@@ -24,6 +24,7 @@ export enum ServerFeatureFlag {
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
PARTNER_NODE_GOVERNANCE_ENABLED = 'partner_node_governance_enabled',
USER_SECRETS_ENABLED = 'user_secrets_enabled',
NODE_REPLACEMENTS = 'node_replacements',
NODE_LIBRARY_ESSENTIALS_ENABLED = 'node_library_essentials_enabled',
@@ -133,6 +134,13 @@ export function useFeatureFlags() {
cachedTeamWorkspacesEnabled
)
},
get partnerNodeGovernanceEnabled() {
return resolveFlag(
ServerFeatureFlag.PARTNER_NODE_GOVERNANCE_ENABLED,
remoteConfig.value.partner_node_governance_enabled,
false
)
},
get userSecretsEnabled() {
return resolveFlag(
ServerFeatureFlag.USER_SECRETS_ENABLED,

View File

@@ -11,7 +11,9 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { useViewErrorsInGraph } from './useViewErrorsInGraph'
const apiMock = vi.hoisted(() => ({
addEventListener: vi.fn(),
getSettings: vi.fn(),
removeEventListener: vi.fn(),
storeSetting: vi.fn(),
storeSettings: vi.fn()
}))

View File

@@ -1091,6 +1091,7 @@
"runWorkflow": "Run workflow (Shift to queue at front)",
"runWorkflowFront": "Run workflow (Queue at front)",
"runWorkflowDisabled": "Workflow contains unsupported nodes (highlighted red). Remove these to run the workflow.",
"runWorkflowDisabledNodes": "Workflows with disabled nodes cannot be run. Check the Errors tab for details.",
"run": "Run",
"stopRunInstant": "Stop Run (Instant)",
"stopRunInstantTooltip": "Stop running",
@@ -1565,6 +1566,8 @@
"Execution": "Execution",
"PLY": "PLY",
"Workspace": "Workspace",
"Members": "Members",
"PartnerNodes": "Allowlist",
"Error System": "Error System",
"Other": "Other",
"Secrets": "Secrets",
@@ -2630,7 +2633,7 @@
"additionalCreditsInfo": "About additional credits",
"additionalCredits": "Additional credits",
"additionalCreditsInUse": "In use",
"usedAfterMonthly": "Used after monthly runs out",
"usedAfterMonthly": "Used after plan credits run out",
"monthlyCreditsUsedUpTitle": "Monthly credits are used up. Refills {date}",
"monthlyCreditsUsedUpTitleNoDate": "Monthly credits are used up",
"monthlyCreditsUsedUpDescription": "You're now spending additional credits.",
@@ -2868,7 +2871,10 @@
"planUpdated": "Your plan has been successfully updated.",
"receiptEmailed": "A receipt has been emailed to you.",
"sendInvites": "Send invites"
}
},
"enterprisePlanName": "Enterprise",
"percentUsed": "{percent}% used",
"usageProgress": "{used} of {total} credits used"
},
"userSettings": {
"title": "My Account Settings",
@@ -2883,7 +2889,7 @@
"workspacePanel": {
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of {count} members",
"inviteLimitReached": "Your workspace is at the member limit",
"tabs": {
"dashboard": "Dashboard",
"planCredits": "Plan & Credits",
@@ -2898,12 +2904,17 @@
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
"tabs": {
"active": "Active",
"pendingCount": "Pending ({count})"
"pendingCount": "Pending ({count})",
"membersCount": "Members ({count})",
"pending": "Pending"
},
"columns": {
"inviteDate": "Invite date",
"expiryDate": "Expiry date",
"role": "Role"
"role": "Role",
"creditsUsed": "Credits used this month",
"email": "Email",
"lastActivity": "Last activity"
},
"actions": {
"resendInvite": "Resend invite",
@@ -2919,14 +2930,23 @@
"contactUs": "Contact us",
"noInvites": "No pending invites",
"noMembers": "No members",
"searchPlaceholder": "Search..."
"searchPlaceholder": "Search...",
"activity": {
"daysAgo": "{count} day ago | {count} days ago",
"hoursAgo": "{n} hr ago",
"justNow": "just now",
"minutesAgo": "{n} min ago",
"never": "—"
},
"membersUsage": "{count} of {max} total members."
},
"menu": {
"editWorkspace": "Edit workspace details",
"leaveWorkspace": "Leave Workspace",
"deleteWorkspace": "Delete Workspace",
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first",
"creatorCannotLeave": "The workspace creator can't leave the workspace they created"
"creatorCannotLeave": "The workspace creator can't leave the workspace they created",
"renameWorkspace": "Rename Workspace"
},
"editWorkspaceDialog": {
"title": "Edit workspace details",
@@ -2951,12 +2971,12 @@
"error": "Failed to remove member"
},
"changeRoleDialog": {
"promoteTitle": "Make {name} an owner?",
"promoteTitle": "Make {name} an admin?",
"promoteIntro": "They'll be able to:",
"promotePermissionCredits": "Add additional credits",
"promotePermissionManage": "Manage members, payment methods, and workspace settings",
"promotePermissionRoles": "Promote and demote other owners (except the workspace creator).",
"promoteConfirm": "Make owner",
"promotePermissionRoles": "Promote and demote other admins (except the workspace creator).",
"promoteConfirm": "Make admin",
"demoteTitle": "Demote {name} to member?",
"demoteMessage": "They'll lose admin access.",
"demoteConfirm": "Demote to member",
@@ -3015,6 +3035,105 @@
"failedToDeleteWorkspace": "Failed to delete workspace",
"failedToLeaveWorkspace": "Failed to leave workspace",
"failedToFetchWorkspaces": "Failed to load workspaces"
},
"charactersLeft": "{count} character left | {count} characters left",
"doubleClickToRename": "Double-click to rename",
"editWorkspaceImage": "Edit workspace image",
"memberLimitDialog": {
"message": "All seats are filled. To invite someone new, remove a member, rescind an invite, or request more seats.",
"title": "Workspace is at the member limit"
},
"requestMore": "Request more",
"workflowQueuedDialog": {
"message": "Max workflow capacity reached. It'll start automatically as capacity opens up. If this happens often, you can also request for more capacity.",
"title": "Your workflow is queued"
},
"billingStatus": {
"ending": {
"body": "Members keep full access until then. Reactivate to keep your shared credits and seats.",
"reactivate": "Reactivate plan",
"title": "Your team plan ends on {date}"
},
"outOfCredits": {
"addCredits": "Add credits",
"body": "Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.",
"bodyNoDate": "Your team has used all its credits. Add more credits to continue generating.",
"dismiss": "Dismiss",
"title": "Out of credits"
},
"paused": {
"body": "This workspace's subscription is paused. Update payment to resume.",
"memberBody": "This workspace's subscription is paused. Your workspace admins need to update the payment method.",
"title": "Subscription paused"
},
"updatePayment": "Update payment",
"warning": {
"body": "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
"title": "Payment declined"
}
},
"overview": {
"changePlan": "Change plan",
"inactive": {
"reactivate": "Reactivate plan",
"subtitle": "Reactivate your team plan to add more members and run workflows",
"subtitleEnterprise": "Reactivate your enterprise plan to add more members and run workflows",
"title": "Inactive team subscription",
"titleEnterprise": "Inactive enterprise subscription"
},
"learnMore": "Learn more",
"managePayment": "Manage payment",
"messageSupport": "Message support",
"paused": "Paused",
"perMonth": "mo",
"pricingTable": "Partner Node pricing table",
"renewsOn": "Renews on {date}",
"seeMore": "See more",
"snapshot": {
"creditsUsed": "Credits used",
"empty": {
"recentActivity": "No activity yet",
"topSpenders": "No credits used yet this month"
},
"lastActivity": "Last activity",
"recentActivity": "Recent activity",
"topSpenders": "Top spenders",
"user": "User"
}
},
"allowlist": {
"disableAll": "Disable all",
"enableAll": "Enable all",
"tabs": {
"partnerNodes": "Partner nodes"
}
},
"partnerNodes": {
"autoEnableLabel": "Automatically enable newly added partner nodes",
"autoEnableSubject": "newly added partner nodes",
"autoEnableVerb": "auto-enable",
"bulkToggle": "Enable or disable selected partner nodes",
"clearSelection": "Clear selection",
"collapseProvider": "Collapse {partner} partner nodes",
"columns": {
"lastModified": "Last modified",
"name": "Partner Node",
"nodes": "Nodes"
},
"description": "Choose which partner nodes your team can use. Workflows with disabled nodes cannot be run.",
"empty": "No partner nodes match your search.",
"expandProvider": "Expand {partner} partner nodes",
"groupCount": "{enabled}/{total} enabled",
"groupToggle": "Enable or disable {partner} partner nodes",
"loadError": "Failed to load partner nodes",
"loading": "Loading partner nodes...",
"neverModified": "—",
"nodeToggle": "Enable or disable {name}",
"retry": "Retry",
"searchPlaceholder": "Search partner nodes",
"selectAll": "Select all partner nodes",
"selectedCount": "{count} node selected | {count} nodes selected",
"updateError": "Failed to update partner nodes"
}
},
"teamWorkspacesDialog": {
@@ -3027,7 +3146,7 @@
"newWorkspace": "New workspace",
"namePlaceholder": "e.g. Marketing Team",
"createWorkspace": "Create workspace",
"nameValidationError": "Name must be 150 characters using letters, numbers, spaces, or common punctuation."
"nameValidationError": "Name must be 130 characters using letters, numbers, spaces, or common punctuation."
},
"workspaceSwitcher": {
"switchWorkspace": "Switch workspace",
@@ -3037,7 +3156,8 @@
"roleMember": "Member",
"createWorkspace": "Create a workspace",
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one.",
"failedToSwitch": "Failed to switch workspace"
"failedToSwitch": "Failed to switch workspace",
"roleAdmin": "Admin"
},
"selectionToolbox": {
"executeButton": {
@@ -3924,6 +4044,12 @@
"errorNodesSummary": "{nodes} nodes — {count} error | {nodes} nodes — {count} errors",
"errorsSummary": "{count} error | {count} errors",
"resolveBeforeRun": "Resolve before running the workflow",
"disabledNodes": {
"title": "Disabled node | Disabled nodes",
"message": "This node has been disabled by your team admin. Use a different node. | These nodes have been disabled by your team admin. Use different nodes.",
"toastDetail": "This node has been disabled by your team admin. | These nodes have been disabled by your team admin.",
"viewDetails": "View details"
},
"expand": "Expand",
"collapse": "Collapse",
"executionErrorOccurred": "An error occurred during execution. Check the Errors tab for details.",
@@ -4423,7 +4549,11 @@
"hideDevOnly": "Hide Dev-Only Nodes",
"hideDevOnlyDescription": "Hides nodes marked as dev-only unless dev mode is enabled",
"hideSubgraph": "Hide Subgraph Nodes",
"hideSubgraphDescription": "Temporarily hides subgraph nodes from node library and search"
"hideSubgraphDescription": "Temporarily hides subgraph nodes from node library and search",
"hideDisabledPartnerNodes": "Hide Admin-Disabled Partner Nodes"
},
"nodeSearch": {
"disabledByTeamAdmin": "This node has been disabled by your team admin. | These nodes have been disabled by your team admin."
},
"secrets": {
"title": "API Keys & Secrets",

View File

@@ -1,67 +1,48 @@
<template>
<div class="relative mx-2">
<div
data-testid="assets-selection-bar"
class="absolute bottom-6 left-1/2 z-40 flex w-full max-w-78 -translate-x-1/2 items-center gap-2 rounded-lg bg-base-foreground p-2 text-base-background shadow-interface"
<SelectionBar
data-testid="assets-selection-bar"
:label="$t('mediaAsset.selection.selectedCount', { count })"
:deselect-label="$t('mediaAsset.selection.deselectAll')"
@deselect="emit('deselect')"
>
<Button
v-tooltip.top="{
value: $t('mediaAsset.selection.downloadSelected'),
showDelay: 300
}"
variant="inverted"
size="icon-lg"
type="button"
data-testid="assets-download-selected"
:aria-label="$t('mediaAsset.selection.downloadSelected')"
class="rounded-lg hover:bg-base-background/10"
@click="emit('download')"
>
<i class="icon-[lucide--download] size-4" />
</Button>
<template v-if="showDelete">
<span class="h-6 w-px bg-base-background/20" aria-hidden="true" />
<Button
v-tooltip.top="{
value: $t('mediaAsset.selection.deselectAll'),
value: $t('mediaAsset.selection.deleteSelected'),
showDelay: 300
}"
variant="inverted"
size="icon-lg"
type="button"
data-testid="assets-deselect-selected"
:aria-label="$t('mediaAsset.selection.deselectAll')"
data-testid="assets-delete-selected"
:aria-label="$t('mediaAsset.selection.deleteSelected')"
class="rounded-lg hover:bg-base-background/10"
@click="emit('deselect')"
@click="emit('delete')"
>
<i class="icon-[lucide--x] size-4" />
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<span class="pr-6 text-sm font-bold whitespace-nowrap tabular-nums">
{{ $t('mediaAsset.selection.selectedCount', { count }) }}
</span>
<div class="ml-auto flex shrink-0 items-center gap-1">
<Button
v-tooltip.top="{
value: $t('mediaAsset.selection.downloadSelected'),
showDelay: 300
}"
variant="inverted"
size="icon-lg"
type="button"
data-testid="assets-download-selected"
:aria-label="$t('mediaAsset.selection.downloadSelected')"
class="rounded-lg hover:bg-base-background/10"
@click="emit('download')"
>
<i class="icon-[lucide--download] size-4" />
</Button>
<template v-if="showDelete">
<span class="h-6 w-px bg-base-background/20" aria-hidden="true" />
<Button
v-tooltip.top="{
value: $t('mediaAsset.selection.deleteSelected'),
showDelay: 300
}"
variant="inverted"
size="icon-lg"
type="button"
data-testid="assets-delete-selected"
:aria-label="$t('mediaAsset.selection.deleteSelected')"
class="rounded-lg hover:bg-base-background/10"
@click="emit('delete')"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
</template>
</div>
</div>
</div>
</template>
</SelectionBar>
</template>
<script setup lang="ts">
import SelectionBar from '@/components/common/SelectionBar.vue'
import Button from '@/components/ui/button/Button.vue'
const { count, showDelete = true } = defineProps<{

View File

@@ -53,6 +53,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
balance: computed(() => state.balance),
subscription: computed(() => state.subscription),
isPaused: computed(() => false),
isActiveSubscription: computed(() => state.isActiveSubscription),
isFreeTier: computed(() => state.isFreeTier),
currentTeamCreditStop: computed(() => state.currentTeamCreditStop),
@@ -97,24 +98,14 @@ const i18n = createI18n({
remaining: 'remaining',
refreshCredits: 'Refresh credits',
monthly: 'Monthly',
refillsDate: 'Refills {date}',
refillsNextCycle: 'Refills next cycle',
creditsUsed: '{used} used',
creditsLeftOfTotal: '{remaining} left of {total}',
monthlyUsageProgress: '{used} of {total} monthly credits used',
yearly: 'Yearly',
percentUsed: '{percent}% used',
usageProgress: '{used} of {total} credits used',
additionalCreditsInfo: 'About additional credits',
additionalCreditsTooltip: 'Credits you add on top of your plan.',
additionalCredits: 'Additional credits',
additionalCreditsInUse: 'In use',
usedAfterMonthly: 'Used after monthly runs out',
monthlyCreditsUsedUpTitle:
'Monthly credits are used up. Refills {date}',
monthlyCreditsUsedUpTitleNoDate: 'Monthly credits are used up',
monthlyCreditsUsedUpDescription:
"You're now spending additional credits.",
outOfCreditsTitle: "You're out of credits. Credits refill {date}",
outOfCreditsTitleNoDate: "You're out of credits",
outOfCreditsDescription: 'Add more credits to continue generating.',
usedAfterMonthly: 'Used after plan credits run out',
addCredits: 'Add credits',
upgradeToAddCredits: 'Upgrade to add credits'
}
@@ -178,27 +169,19 @@ describe('CreditsTile', () => {
it('renders the monthly usage bar and additional breakdown', () => {
activeProSubscription()
const { container } = renderTile()
// PRO monthly allowance = 21,100; remaining 422 -> used 20,678.
// PRO monthly allowance = 21,100; remaining 422 -> used 20,678 -> 98%.
expect(container.textContent).toContain('Monthly')
expect(container.textContent).toMatch(/Refills Feb/)
expect(container.textContent).toContain('20,678 used')
expect(container.textContent).toContain('422 left of 21,100')
expect(container.textContent).toContain('98% used')
expect(container.textContent).toContain('Additional credits')
expect(container.textContent).toContain('633')
expect(container.textContent).toContain('Used after monthly runs out')
expect(container.textContent).toContain('Used after plan credits run out')
})
it('renders a compact monthly summary for narrow containers', () => {
activeProSubscription()
const { container } = renderTile()
expect(container.textContent).toContain('422 left of 21K')
})
it('uses the team credit stop monthly grant for the monthly total', () => {
it('uses the team credit stop grant for a monthly allowance', () => {
state.isActiveSubscription = true
state.subscription = {
tier: 'TEAM',
duration: 'ANNUAL',
duration: 'MONTHLY',
renewalDate: '2026-02-20T12:00:00Z'
}
state.currentTeamCreditStop = {
@@ -207,13 +190,15 @@ describe('CreditsTile', () => {
stop_usd: 2500
}
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
const { container } = renderTile()
// Monthly total is the stop's raw monthly grant, not the tier fallback,
// and is not multiplied by 12 for annual billing.
expect(container.textContent).toContain('422 left of 527,500')
renderTile()
// Allowance is the stop's grant, not the tier fallback.
expect(screen.getByRole('progressbar')).toHaveAttribute(
'aria-valuemax',
'527500'
)
})
it('uses the per-month nominal grant for an annual personal tier', () => {
it('grants the full year upfront for an annual plan', () => {
state.isActiveSubscription = true
state.subscription = {
tier: 'PRO',
@@ -221,35 +206,25 @@ describe('CreditsTile', () => {
renewalDate: '2026-02-20T12:00:00Z'
}
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
const { container } = renderTile()
// Annual billing still grants the monthly nominal (21,100), not 12x.
expect(container.textContent).toContain('422 left of 21,100')
expect(container.textContent).not.toContain('253,200')
renderTile()
// Annual plans grant the whole year at once: 21,100 x 12.
expect(screen.getByRole('progressbar')).toHaveAttribute(
'aria-valuemax',
'253200'
)
})
it('falls back to a dateless refills label when renewal date is missing', () => {
activeProSubscription()
state.subscription = { tier: 'PRO', duration: 'MONTHLY', renewalDate: null }
const { container } = renderTile()
expect(container.textContent).toContain('Refills next cycle')
expect(container.textContent).not.toContain('Refills Feb')
})
it('uses a dateless out-of-credits notice when renewal date is invalid', () => {
activeProSubscription()
it('labels the allowance by billing duration (yearly for annual)', () => {
state.isActiveSubscription = true
state.subscription = {
tier: 'PRO',
duration: 'MONTHLY',
renewalDate: 'not-a-date'
duration: 'ANNUAL',
renewalDate: '2026-02-20T12:00:00Z'
}
state.balance = {
amountMicros: 0,
cloudCreditBalanceMicros: 0,
prepaidBalanceMicros: 0
}
const { container } = renderTile()
expect(container.textContent).toContain("You're out of credits")
expect(container.textContent).not.toContain('Credits refill')
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
renderTile()
expect(screen.getByText('Yearly')).toBeInTheDocument()
expect(screen.queryByText('Monthly')).not.toBeInTheDocument()
})
it('hides the breakdown and forces zeros in the zero state', () => {
@@ -271,11 +246,9 @@ describe('CreditsTile', () => {
expect(screen.queryByText('Add credits')).toBeNull()
})
it('shows no depletion notice or in-use badge while monthly credits remain', () => {
it('shows no in-use badge while monthly credits remain', () => {
activeProSubscription()
const { container } = renderTile()
expect(container.textContent).not.toContain('Monthly credits are used up')
expect(container.textContent).not.toContain("You're out of credits")
renderTile()
expect(screen.queryByText('In use')).toBeNull()
})
@@ -286,42 +259,29 @@ describe('CreditsTile', () => {
cloudCreditBalanceMicros: 0,
prepaidBalanceMicros: 300
}
const { container } = renderTile()
expect(container.textContent).toContain(
'Monthly credits are used up. Refills Feb 20'
)
expect(container.textContent).toContain(
"You're now spending additional credits."
)
renderTile()
expect(screen.getByText('In use')).toBeTruthy()
expect(screen.getByText('Add credits').dataset.variant).toBe('secondary')
expect(screen.getByText('Add credits').dataset.variant).toBe('tertiary')
})
it('emphasizes add-credits when fully out of credits', () => {
it('emphasizes add-credits when fully out of credits, without a punch-out notice', () => {
activeProSubscription()
state.balance = {
amountMicros: 0,
cloudCreditBalanceMicros: 0,
prepaidBalanceMicros: 0
}
const { container } = renderTile()
expect(container.textContent).toContain(
"You're out of credits. Credits refill Feb 20"
)
expect(container.textContent).toContain(
'Add more credits to continue generating.'
)
renderTile()
expect(screen.queryByText('In use')).toBeNull()
expect(screen.getByText('Add credits').dataset.variant).toBe('inverted')
})
it('suppresses the depletion notice until the balance has loaded', () => {
it('shows no in-use badge until the balance has loaded', () => {
activeProSubscription()
state.balance = null
state.isLoading = true
const { container } = renderTile()
expect(container.textContent).not.toContain('Monthly credits are used up')
expect(container.textContent).not.toContain("You're out of credits")
renderTile()
expect(screen.queryByText('In use')).toBeNull()
})
it('routes add-credits through telemetry + the top-up dialog', async () => {

View File

@@ -1,6 +1,15 @@
<template>
<div
class="@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5"
:class="
cn(
'@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5 transition-opacity',
// Paused subscriptions can't spend credits, so dim the whole tile to
// read as frozen and defer to the Update-payment banner. A lapsed plan
// (frozen) reads the same way.
(isPaused || frozen) && 'opacity-50',
customClass
)
"
>
<Button
variant="muted-textonly"
@@ -19,8 +28,10 @@
</div>
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
<div v-else class="flex items-baseline gap-2">
<i class="icon-[lucide--component] size-4 self-center text-credit" />
<span class="text-2xl leading-none font-bold">{{ displayTotal }}</span>
<i class="icon-[lucide--coins] size-4 self-center text-credit" />
<span class="text-2xl leading-none font-bold tabular-nums">{{
displayTotal
}}</span>
<span class="text-sm text-muted @max-[300px]:hidden">{{
$t('subscription.remaining')
}}</span>
@@ -28,37 +39,23 @@
</div>
<template v-if="showBreakdown">
<div
v-if="emptyStateNotice"
class="flex items-start gap-2 rounded-lg bg-base-background p-3 text-sm"
>
<i
class="mt-0.5 icon-[lucide--info] size-4 shrink-0 text-base-foreground"
/>
<div class="flex flex-col gap-1">
<span class="text-base-foreground">{{ emptyStateNotice.title }}</span>
<span class="text-muted">{{ emptyStateNotice.description }}</span>
</div>
</div>
<div
v-if="showBar"
:class="cn('flex flex-col gap-2', isMonthlyDepleted && 'opacity-30')"
:class="cn('flex flex-col gap-2', isAllowanceDepleted && 'opacity-30')"
>
<div class="flex items-center justify-between text-sm">
<span class="text-text-primary">{{
$t('subscription.monthly')
}}</span>
<span class="text-muted">{{ cycleLabel }}</span>
<span class="text-muted">
{{ refillsLabel }}
{{ cycleStatusLabel }}
</span>
</div>
<div
role="progressbar"
:aria-label="cycleLabel"
:aria-valuenow="usage.used"
:aria-valuemin="0"
:aria-valuemax="monthlyTotalCredits ?? 0"
:aria-valuetext="monthlyUsageLabel"
:aria-valuemax="allowanceTotalCredits ?? 0"
:aria-valuetext="cycleUsageLabel"
class="h-2 w-full overflow-hidden rounded-full bg-secondary-background-hover"
>
<div
@@ -66,40 +63,6 @@
:style="{ width: usedBarWidth }"
/>
</div>
<div class="flex items-center justify-between gap-2 text-sm">
<Skeleton
v-if="isLoadingBalance"
class="@max-[300px]:hidden"
width="5rem"
height="1rem"
/>
<span v-else class="text-muted @max-[300px]:hidden">
{{ $t('subscription.creditsUsed', { used: usedDisplay }) }}
</span>
<Skeleton v-if="isLoadingBalance" width="9rem" height="1rem" />
<span
v-else
class="flex items-center gap-1 font-bold text-text-primary"
>
<i class="icon-[lucide--component] size-4 text-credit" />
<span class="@max-[180px]:hidden">
{{
$t('subscription.creditsLeftOfTotal', {
remaining: monthlyBonusCredits,
total: monthlyTotalDisplay
})
}}
</span>
<span class="hidden @max-[180px]:inline">
{{
$t('subscription.creditsLeftOfTotal', {
remaining: monthlyRemainingCompact,
total: monthlyTotalCompact
})
}}
</span>
</span>
</div>
</div>
<div class="h-px w-full bg-interface-stroke" />
@@ -118,7 +81,7 @@
variant="muted-textonly"
size="icon-sm"
:aria-label="$t('subscription.additionalCreditsInfo')"
class="text-muted"
class="flex cursor-help appearance-none items-center border-none bg-transparent p-0 text-muted transition-colors hover:text-text-primary"
>
<i class="icon-[lucide--info] size-4" />
</Button>
@@ -132,9 +95,9 @@
<Skeleton v-if="isLoadingBalance" width="3rem" height="1rem" />
<span
v-else
class="flex items-center gap-1 font-bold text-text-primary"
class="flex items-center gap-1 font-bold text-text-primary tabular-nums"
>
<i class="icon-[lucide--component] size-4 text-credit" />
<i class="icon-[lucide--coins] size-4 text-credit" />
{{ displayPrepaid }}
</span>
</div>
@@ -156,15 +119,10 @@
</Button>
<Button
v-else
:variant="isOutOfCredits ? 'inverted' : 'secondary'"
:variant="isOutOfCredits ? 'inverted' : 'tertiary'"
size="lg"
:class="
cn(
'w-full font-normal',
!isOutOfCredits &&
'bg-interface-menu-component-surface-selected text-text-primary'
)
"
class="w-full font-normal"
:disabled="isPaused || frozen"
@click="handleAddCredits"
>
{{ $t('subscription.addCredits') }}
@@ -178,6 +136,7 @@ import { cn } from '@comfyorg/tailwind-utils'
import { useEventListener } from '@vueuse/core'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted } from 'vue'
import type { HTMLAttributes } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCredits } from '@/base/credits/comfyCredits'
@@ -186,40 +145,45 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import {
DEFAULT_TIER_KEY,
TIER_TO_KEY,
getTierCredits
} from '@/platform/cloud/subscription/constants/tierPricing'
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
import { useTelemetry } from '@/platform/telemetry'
import { consumePendingTopup } from '@/platform/telemetry/topupTracker'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useDialogService } from '@/services/dialogService'
const { zeroState = false } = defineProps<{
const {
zeroState = false,
frozen = false,
class: customClass
} = defineProps<{
/** Forces the zero-credit display (e.g. unsubscribed / member view). */
zeroState?: boolean
/**
* Renders the full breakdown but dimmed and non-interactive, for a lapsed
* subscription that still has a shape to show. Mirrors the paused treatment.
*/
frozen?: boolean
class?: HTMLAttributes['class']
}>()
const { locale, t } = useI18n()
const {
subscription,
isPaused,
balance,
isActiveSubscription,
isFreeTier,
currentTeamCreditStop,
fetchBalance,
fetchStatus
} = useBillingContext()
const {
monthlyBonusCredits,
prepaidCredits,
totalCredits,
monthlyBonusCreditsValue,
prepaidCreditsValue,
isLoadingBalance
isLoadingBalance,
allowanceTotalCredits,
usage
} = useSubscriptionCredits()
const { permissions } = useWorkspaceUI()
const { showPricingTable } = useSubscriptionDialog()
@@ -227,40 +191,18 @@ const { wrapWithErrorHandlingAsync } = useErrorHandling()
const dialogService = useDialogService()
const telemetry = useTelemetry()
const tierKey = computed(() => {
const tier = subscription.value?.tier
if (!tier) return DEFAULT_TIER_KEY
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
})
const monthlyTotalCredits = computed<number | null>(() => {
const teamStop = currentTeamCreditStop.value
if (teamStop) return teamStop.credits_monthly
return getTierCredits(tierKey.value)
})
const usage = computed(() =>
computeMonthlyUsage(
monthlyBonusCreditsValue.value,
monthlyTotalCredits.value ?? 0
)
const cycleLabel = computed(() =>
subscription.value?.duration === 'ANNUAL'
? t('subscription.yearly')
: t('subscription.monthly')
)
const refillsDateShort = computed(() => {
const raw = subscription.value?.renewalDate
if (!raw) return ''
const date = new Date(raw)
return Number.isNaN(date.getTime())
? ''
: date.toLocaleDateString(locale.value, { month: 'short', day: 'numeric' })
})
const cycleUsedPercent = computed(() =>
Math.round(usage.value.usedFraction * 100)
)
const hasRefillsDate = computed(() => refillsDateShort.value !== '')
const refillsLabel = computed(() =>
hasRefillsDate.value
? t('subscription.refillsDate', { date: refillsDateShort.value })
: t('subscription.refillsNextCycle')
const cycleStatusLabel = computed(() =>
t('subscription.percentUsed', { percent: cycleUsedPercent.value })
)
const formatCreditCount = (value: number) =>
@@ -270,82 +212,58 @@ const formatCreditCount = (value: number) =>
numberOptions: { maximumFractionDigits: 0 }
})
const monthlyTotalDisplay = computed(() => {
const total = monthlyTotalCredits.value
const allowanceTotalDisplay = computed(() => {
const total = allowanceTotalCredits.value
return total === null ? '—' : formatCreditCount(total)
})
const usedDisplay = computed(() => formatCreditCount(usage.value.used))
const compactNumber = computed(
() => new Intl.NumberFormat(locale.value, { notation: 'compact' })
)
const monthlyRemainingCompact = computed(() =>
compactNumber.value.format(monthlyBonusCreditsValue.value)
)
const monthlyTotalCompact = computed(() => {
const total = monthlyTotalCredits.value
return total === null ? '—' : compactNumber.value.format(total)
})
const displayTotal = computed(() => (zeroState ? '0' : totalCredits.value))
const displayPrepaid = computed(() => (zeroState ? '0' : prepaidCredits.value))
const usedBarWidth = computed(
() => `${(usage.value.usedFraction * 100).toFixed(2)}%`
)
const monthlyUsageLabel = computed(() =>
t('subscription.monthlyUsageProgress', {
const cycleUsageLabel = computed(() =>
t('subscription.usageProgress', {
used: usedDisplay.value,
total: monthlyTotalDisplay.value
total: allowanceTotalDisplay.value
})
)
const showBreakdown = computed(() => isActiveSubscription.value && !zeroState)
const showBreakdown = computed(
() => (isActiveSubscription.value || frozen) && !zeroState
)
const showBar = computed(
() =>
showBreakdown.value &&
monthlyTotalCredits.value !== null &&
monthlyTotalCredits.value > 0
allowanceTotalCredits.value !== null &&
allowanceTotalCredits.value > 0
)
const showActionButton = computed(
() => isActiveSubscription.value && !zeroState && permissions.value.canTopUp
() =>
(isActiveSubscription.value || frozen) &&
!zeroState &&
permissions.value.canTopUp
)
const isMonthlyDepleted = computed(
const isAllowanceDepleted = computed(
() =>
!isPaused.value &&
!frozen &&
showBar.value &&
!isLoadingBalance.value &&
balance.value != null &&
monthlyBonusCreditsValue.value <= 0
)
const isOutOfCredits = computed(
() => isMonthlyDepleted.value && prepaidCreditsValue.value <= 0
)
const isSpendingAdditional = computed(
() => isMonthlyDepleted.value && prepaidCreditsValue.value > 0
() => isAllowanceDepleted.value && prepaidCreditsValue.value > 0
)
// Fully out (monthly depleted and no additional credits left): emphasize the
// add-credits button. Spending-additional keeps the quieter tertiary.
const isOutOfCredits = computed(
() => isAllowanceDepleted.value && prepaidCreditsValue.value <= 0
)
const emptyStateNotice = computed(() => {
if (isOutOfCredits.value) {
return {
title: hasRefillsDate.value
? t('subscription.outOfCreditsTitle', { date: refillsDateShort.value })
: t('subscription.outOfCreditsTitleNoDate'),
description: t('subscription.outOfCreditsDescription')
}
}
if (isMonthlyDepleted.value) {
return {
title: hasRefillsDate.value
? t('subscription.monthlyCreditsUsedUpTitle', {
date: refillsDateShort.value
})
: t('subscription.monthlyCreditsUsedUpTitleNoDate'),
description: t('subscription.monthlyCreditsUsedUpDescription')
}
}
return null
})
const handleRefresh = wrapWithErrorHandlingAsync(async () => {
await Promise.all([fetchBalance(), fetchStatus()])

View File

@@ -6,6 +6,12 @@ import {
formatCreditsFromCents
} from '@/base/credits/comfyCredits'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import {
DEFAULT_TIER_KEY,
TIER_TO_KEY,
getTierCredits
} from '@/platform/cloud/subscription/constants/tierPricing'
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
/**
* Composable for handling subscription credit calculations and formatting.
@@ -64,12 +70,44 @@ export function useSubscriptionCredits() {
creditsFromMicros(toValue(billingContext.balance)?.prepaidBalanceMicros)
)
// Total credits granted for the current billing cycle. Team plans read the
// credit stop; personal tiers read the tier grant. Annual plans front-load the
// whole year, so multiply the monthly nominal by the cycle length.
const cycleMonths = computed(() =>
toValue(billingContext.subscription)?.duration === 'ANNUAL' ? 12 : 1
)
const allowanceTotalCredits = computed<number | null>(() => {
const teamStop = toValue(billingContext.currentTeamCreditStop)
const tier = toValue(billingContext.subscription)?.tier
const tierKey = tier
? (TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY)
: DEFAULT_TIER_KEY
const monthly = teamStop
? teamStop.credits_monthly
: getTierCredits(tierKey)
return monthly === null ? null : monthly * cycleMonths.value
})
// Usage of that allowance drives the credits bar. Paused plans read as unused
// (credits are frozen), so force it to zero.
const usage = computed(() => {
const base = computeMonthlyUsage(
monthlyBonusCreditsValue.value,
allowanceTotalCredits.value ?? 0
)
return toValue(billingContext.isPaused)
? { ...base, used: 0, usedFraction: 0 }
: base
})
return {
totalCredits,
monthlyBonusCredits,
prepaidCredits,
monthlyBonusCreditsValue,
prepaidCreditsValue,
isLoadingBalance
isLoadingBalance,
allowanceTotalCredits,
usage
}
}

View File

@@ -107,6 +107,7 @@ export type RemoteConfig = {
manager_survey_url?: string
linear_toggle_enabled?: boolean
team_workspaces_enabled?: boolean
partner_node_governance_enabled?: boolean
user_secrets_enabled?: boolean
node_library_essentials_enabled?: boolean
free_tier_credits?: number

View File

@@ -1,5 +1,11 @@
<template>
<BaseModalLayout content-title="" data-testid="settings-dialog" size="full">
<BaseModalLayout
content-title=""
data-testid="settings-dialog"
size="full"
header-height-class="h-22"
:content-padding="isWorkspacePanel ? 'flush' : 'default'"
>
<template #leftPanelHeaderTitle>
<i class="icon-[lucide--settings]" />
<h2 class="text-neutral text-base">{{ $t('g.settings') }}</h2>
@@ -48,6 +54,7 @@
id="keybinding-panel-header"
class="flex-1"
/>
<WorkspaceSettingsHeader v-else-if="isWorkspacePanel" />
</template>
<template #header-right-area>
@@ -55,6 +62,7 @@
v-if="activeCategoryKey === 'keybinding'"
id="keybinding-panel-actions"
/>
<WorkspaceMenuButton v-else-if="isWorkspacePanel" />
</template>
<template #content>
@@ -93,8 +101,11 @@ import NavTitle from '@/components/widget/nav/NavTitle.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
import WorkspaceMenuButton from '@/platform/workspace/components/dialogs/settings/WorkspaceMenuButton.vue'
import WorkspaceSettingsHeader from '@/platform/workspace/components/dialogs/settings/WorkspaceSettingsHeader.vue'
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
import { useSettingUI } from '@/platform/settings/composables/useSettingUI'
import { useSettingsNavigation } from '@/platform/settings/composables/useSettingsNavigation'
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import type {
@@ -135,6 +146,14 @@ const { fetchBalance } = useBillingContext()
const navRef = ref<HTMLElement | null>(null)
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)
// Let panels deep-link into a sibling panel (e.g. Overview → Members).
const { requestedPanelKey } = useSettingsNavigation()
watch(requestedPanelKey, (key) => {
if (!key) return
activeCategoryKey.value = key
requestedPanelKey.value = null
})
const searchableNavItems = computed(() =>
navGroups.value.flatMap((g) =>
g.items.map((item) => ({
@@ -172,6 +191,17 @@ const activePanel = computed(() => {
return findPanelByKey(activeCategoryKey.value)
})
const WORKSPACE_PANEL_KEYS: SettingPanelType[] = [
'workspace',
'workspace-members',
'workspace-partner-nodes'
]
const isWorkspacePanel = computed(
() =>
!!activeCategoryKey.value &&
WORKSPACE_PANEL_KEYS.some((key) => key === activeCategoryKey.value)
)
const getGroupSortOrder = (group: SettingTreeNode): number =>
Math.max(0, ...flattenTree<SettingParams>(group).map((s) => s.sortOrder ?? 0))

View File

@@ -19,7 +19,8 @@ const env = vi.hoisted(() => {
teamWorkspacesEnabled: false,
userSecretsEnabled: false,
isActiveSubscription: false,
billingType: 'legacy' as 'legacy' | 'workspace'
billingType: 'legacy' as 'legacy' | 'workspace',
canManagePartnerNodes: false
}
const fakeRef = <K extends keyof typeof state>(key: K) => ({
get value() {
@@ -75,6 +76,16 @@ vi.mock('@/platform/settings/settingStore', () => ({
getSettingInfo: vi.fn()
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: {
get value() {
return { canManagePartnerNodes: env.state.canManagePartnerNodes }
}
}
})
}))
interface MockSettingParams {
id: string
name: string
@@ -116,7 +127,8 @@ describe('useSettingUI', () => {
teamWorkspacesEnabled: false,
userSecretsEnabled: false,
isActiveSubscription: false,
billingType: 'legacy'
billingType: 'legacy',
canManagePartnerNodes: false
})
vi.mocked(useSettingStore).mockReturnValue({
@@ -233,5 +245,17 @@ describe('useSettingUI', () => {
}
}
})
it('shows the partner nodes entry only to owners and admins', () => {
env.state.canManagePartnerNodes = false
expect(navKeys(useSettingUI().navGroups.value)).not.toContain(
'workspace-partner-nodes'
)
env.state.canManagePartnerNodes = true
expect(navKeys(useSettingUI().navGroups.value)).toContain(
'workspace-partner-nodes'
)
})
})
})

View File

@@ -13,6 +13,7 @@ import {
} from '@/platform/settings/settingStore'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import type { SettingPanelType, SettingParams } from '@/platform/settings/types'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import type { NavGroupData } from '@/types/navTypes'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
@@ -28,6 +29,7 @@ const CATEGORY_ICONS: Record<string, string> = {
LiteGraph: 'icon-[lucide--workflow]',
'Mask Editor': 'icon-[lucide--pen-tool]',
Other: 'icon-[lucide--ellipsis]',
PartnerNodes: 'icon-[lucide--shield-check]',
PlanCredits: 'icon-[lucide--credit-card]',
secrets: 'icon-[lucide--key-round]',
'server-config': 'icon-[lucide--server]',
@@ -54,6 +56,7 @@ export function useSettingUI(
const { flags } = useFeatureFlags()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const { isActiveSubscription, type: billingType } = useBillingContext()
const { permissions } = useWorkspaceUI()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
@@ -188,10 +191,40 @@ export function useSettingUI(
)
}
const membersPanel: SettingPanelItem = {
node: {
key: 'workspace-members',
label: 'Members',
children: []
},
component: defineAsyncComponent(
() =>
import('@/platform/workspace/components/dialogs/settings/WorkspaceMembersPanelContent.vue')
)
}
const partnerNodesPanel: SettingPanelItem = {
node: {
key: 'workspace-partner-nodes',
label: 'PartnerNodes',
children: []
},
component: defineAsyncComponent(
() =>
import('@/platform/workspace/components/dialogs/settings/AllowlistPanelContent.vue')
)
}
const shouldShowWorkspacePanel = computed(
() => teamWorkspacesEnabled.value && isLoggedIn.value
)
// Partner-node governance is Owner/Admin-only; Members never see the tab.
const shouldShowPartnerNodesPanel = computed(
() =>
shouldShowWorkspacePanel.value && permissions.value.canManagePartnerNodes
)
const secretsPanel: SettingPanelItem = {
node: {
key: 'secrets',
@@ -245,7 +278,8 @@ export function useSettingUI(
aboutPanel,
creditsPanel,
userPanel,
...(shouldShowWorkspacePanel.value ? [workspacePanel] : []),
...(shouldShowWorkspacePanel.value ? [workspacePanel, membersPanel] : []),
...(shouldShowPartnerNodesPanel.value ? [partnerNodesPanel] : []),
keybindingPanel,
extensionPanel,
...(isDesktop ? [serverConfigPanel] : []),
@@ -295,7 +329,10 @@ export function useSettingUI(
key: 'workspace',
label: 'Workspace',
children: [
...(shouldShowWorkspacePanel.value ? [workspacePanel.node] : []),
...(shouldShowWorkspacePanel.value
? [workspacePanel.node, membersPanel.node]
: []),
...(shouldShowPartnerNodesPanel.value ? [partnerNodesPanel.node] : []),
...(isLoggedIn.value &&
!(isCloud && window.__CONFIG__?.subscription_required)
? [creditsPanel.node]

View File

@@ -0,0 +1,15 @@
import { ref } from 'vue'
import type { SettingPanelType } from '@/platform/settings/types'
// A one-shot request to switch the open Settings dialog to another panel, so a
// panel's content can deep-link into a sibling panel (e.g. Overview → Members).
const requestedPanelKey = ref<SettingPanelType | null>(null)
export function useSettingsNavigation() {
function navigateToPanel(key: SettingPanelType) {
requestedPanelKey.value = key
}
return { requestedPanelKey, navigateToPanel }
}

View File

@@ -87,3 +87,5 @@ export type SettingPanelType =
| 'subscription'
| 'user'
| 'workspace'
| 'workspace-members'
| 'workspace-partner-nodes'

View File

@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockApiClient, mockGetAuthHeaderOrThrow } = vi.hoisted(() => ({
mockApiClient: {
get: vi.fn(),
patch: vi.fn()
},
mockGetAuthHeaderOrThrow: vi.fn()
}))
vi.mock('axios', () => ({
default: {
create: vi.fn(() => mockApiClient)
}
}))
vi.mock('@/platform/auth/unified/remintRetry', () => ({
attachUnifiedRemintInterceptor: vi.fn()
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: vi.fn((path: string) => `/api${path}`)
}
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
getAuthHeaderOrThrow: mockGetAuthHeaderOrThrow
})
}))
import { partnerNodesApi } from './partnerNodesApi'
const AUTH_HEADER = { Authorization: 'Bearer test-token' }
describe('partnerNodesApi', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetAuthHeaderOrThrow.mockResolvedValue(AUTH_HEADER)
})
it('lists partner-node governance from the workspace resource', async () => {
const response = { partner_nodes: [], auto_enable_new: false }
mockApiClient.get.mockResolvedValue({ data: response })
await expect(partnerNodesApi.list()).resolves.toEqual(response)
expect(mockApiClient.get).toHaveBeenCalledWith(
'/api/workspace/partner-nodes',
{ headers: AUTH_HEADER }
)
})
it('updates one node through the bulk mutation contract', async () => {
mockApiClient.patch.mockResolvedValue({})
await partnerNodesApi.setEnabled('PartnerNode', false)
expect(mockApiClient.patch).toHaveBeenCalledWith(
'/api/workspace/partner-nodes',
{ node_ids: ['PartnerNode'], enabled: false },
{ headers: AUTH_HEADER }
)
})
it('updates a filtered set through the same resource', async () => {
mockApiClient.patch.mockResolvedValue({})
await partnerNodesApi.setEnabledBulk(['NodeA', 'NodeB'], true)
expect(mockApiClient.patch).toHaveBeenCalledWith(
'/api/workspace/partner-nodes',
{ node_ids: ['NodeA', 'NodeB'], enabled: true },
{ headers: AUTH_HEADER }
)
})
it('updates the default for newly cataloged nodes', async () => {
mockApiClient.patch.mockResolvedValue({})
await partnerNodesApi.setAutoEnableNew(true)
expect(mockApiClient.patch).toHaveBeenCalledWith(
'/api/workspace/partner-nodes',
{ auto_enable_new: true },
{ headers: AUTH_HEADER }
)
})
})

View File

@@ -0,0 +1,86 @@
import axios from 'axios'
import { attachUnifiedRemintInterceptor } from '@/platform/auth/unified/remintRetry'
import { api } from '@/scripts/api'
import { useAuthStore } from '@/stores/authStore'
/** A partner (paid-API) node the workspace can allow or block. */
export interface PartnerNode {
/** Canonical Comfy node type ID; matches the /object_info object key. */
id: string
name: string
partner: string
/** ISO date of the last governance change, or null if never modified. */
last_modified: string | null
enabled: boolean
}
export interface PartnerNodesResponse {
partner_nodes: PartnerNode[]
/** Workspace default applied to newly added partner nodes. */
auto_enable_new: boolean
}
interface BulkSetEnabledPayload {
node_ids: string[]
enabled: boolean
}
interface SetAutoEnablePayload {
auto_enable_new: boolean
}
const partnerNodesApiClient = axios.create({
timeout: 10000,
headers: { 'Content-Type': 'application/json' }
})
attachUnifiedRemintInterceptor(partnerNodesApiClient)
async function authHeader() {
return useAuthStore().getAuthHeaderOrThrow()
}
export const partnerNodesApi = {
/** Readable by every active workspace member. */
async list(): Promise<PartnerNodesResponse> {
const headers = await authHeader()
const response = await partnerNodesApiClient.get<PartnerNodesResponse>(
api.apiURL('/workspace/partner-nodes'),
{ headers }
)
return response.data
},
async setEnabled(nodeId: string, enabled: boolean): Promise<void> {
const headers = await authHeader()
const payload: BulkSetEnabledPayload = {
node_ids: [nodeId],
enabled
}
await partnerNodesApiClient.patch(
api.apiURL('/workspace/partner-nodes'),
payload,
{ headers }
)
},
async setEnabledBulk(nodeIds: string[], enabled: boolean): Promise<void> {
const headers = await authHeader()
const payload: BulkSetEnabledPayload = { node_ids: nodeIds, enabled }
await partnerNodesApiClient.patch(
api.apiURL('/workspace/partner-nodes'),
payload,
{ headers }
)
},
async setAutoEnableNew(autoEnableNew: boolean): Promise<void> {
const headers = await authHeader()
const payload: SetAutoEnablePayload = { auto_enable_new: autoEnableNew }
await partnerNodesApiClient.patch(
api.apiURL('/workspace/partner-nodes'),
payload,
{ headers }
)
}
}

View File

@@ -37,6 +37,11 @@ export interface Member {
// billing lifecycle actions (cancel / reactivate / downgrade).
// Optional: the cloud OpenAPI does not carry this field yet.
is_original_owner?: boolean
// Last time the member ran or interacted with the workspace, and the credits
// they've consumed in the current billing cycle. Optional: the cloud OpenAPI
// does not carry these fields yet.
last_active_at?: string | null
credits_used_this_month?: number
}
interface PaginationInfo {
@@ -244,6 +249,7 @@ export type BillingSubscriptionStatus =
| 'scheduled'
| 'ended'
| 'canceled'
| 'paused'
export type BillingStatus =
| 'awaiting_payment_method'

View File

@@ -59,7 +59,7 @@
<!-- Credits Section -->
<div class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i class="icon-[lucide--coins] text-sm text-amber-400" />
<Skeleton
v-if="isLoadingBalance"
width="4rem"

View File

@@ -129,7 +129,7 @@
{{ t('subscription.monthlyCreditsPerMemberLabel') }}
</span>
<div class="flex flex-row items-center gap-1">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i class="icon-[lucide--coins] size-4 text-amber-400" />
<span
class="font-inter text-sm/normal font-bold text-base-foreground"
>

View File

@@ -3,7 +3,7 @@
<!-- Loading state while subscription is being set up -->
<div
v-if="isSettingUp"
class="rounded-2xl border border-interface-stroke p-6"
class="rounded-2xl border border-interface-stroke/60 p-6"
>
<div class="flex items-center gap-2 py-4 text-muted-foreground">
<i class="pi pi-spin pi-spinner" />
@@ -14,7 +14,7 @@
<!-- Billing data still loading: avoid rendering a false Free/$0 plan -->
<div
v-else-if="isLoading && !subscription"
class="rounded-2xl border border-interface-stroke p-6"
class="rounded-2xl border border-interface-stroke/60 p-6"
>
<div class="flex items-center gap-2 py-4 text-muted-foreground">
<i class="pi pi-spin pi-spinner" />
@@ -25,7 +25,7 @@
<!-- Billing fetch failed: offer retry rather than a misleading Free plan -->
<div
v-else-if="error && !subscription"
class="flex flex-col items-start gap-3 rounded-2xl border border-interface-stroke p-6"
class="flex flex-col items-start gap-3 rounded-2xl border border-interface-stroke/60 p-6"
>
<div class="flex items-center gap-2 text-text-secondary">
<i class="pi pi-exclamation-circle text-danger" />
@@ -67,7 +67,7 @@
</div>
</div>
<div class="rounded-2xl border border-interface-stroke p-6">
<div class="rounded-2xl border border-interface-stroke/60 p-6">
<div>
<div
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-2"
@@ -439,11 +439,13 @@ const subscriptionTierName = computed(() => {
: baseName
})
const planDisplayName = computed(() =>
isInPersonalWorkspace.value
? subscriptionTierName.value
const planDisplayName = computed(() => {
if (isInPersonalWorkspace.value) return subscriptionTierName.value
// 'ENTERPRISE' is a wire tier not yet in the generated SubscriptionTier union.
return (subscription.value?.tier as string | null) === 'ENTERPRISE'
? t('subscription.enterprisePlanName')
: t('subscription.teamPlanName')
)
})
const tierKey = computed(() => {
const tier = subscription.value?.tier

View File

@@ -86,7 +86,7 @@
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
<i class="icon-[lucide--coins] size-4 shrink-0 text-gold-500" />
</template>
</FormattedNumberStepper>
</div>
@@ -98,7 +98,7 @@
v-if="isBelowMin"
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-red-500"
>
<i class="icon-[lucide--component] size-4" />
<i class="icon-[lucide--coins] size-4" />
{{
$t('credits.topUp.minRequired', {
credits: formatNumber(usdToCredits(MIN_AMOUNT))
@@ -109,7 +109,7 @@
v-if="showCeilingWarning"
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-gold-500"
>
<i class="icon-[lucide--component] size-4" />
<i class="icon-[lucide--coins] size-4" />
{{
$t('credits.topUp.maxAllowed', {
credits: formatNumber(usdToCredits(MAX_AMOUNT))

View File

@@ -1,22 +1,37 @@
<template>
<div
class="flex aspect-square size-8 items-center justify-center rounded-md text-base font-semibold text-white"
:style="{
background: gradient,
textShadow: '0 1px 2px rgba(0, 0, 0, 0.2)'
}"
:class="
cn(
'flex aspect-square size-8 items-center justify-center overflow-hidden rounded-md text-base font-semibold text-white',
$attrs.class as string
)
"
:style="imageUrl ? undefined : { background: gradient, textShadow }"
>
{{ letter }}
<img
v-if="imageUrl"
:src="imageUrl"
:alt="workspaceName"
class="size-full object-cover"
/>
<template v-else>{{ letter }}</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const { workspaceName } = defineProps<{
import { cn } from '@comfyorg/tailwind-utils'
defineOptions({ inheritAttrs: false })
const { workspaceName, imageUrl } = defineProps<{
workspaceName: string
imageUrl?: string
}>()
const textShadow = '0 1px 2px rgba(0, 0, 0, 0.2)'
const letter = computed(() => workspaceName?.charAt(0)?.toUpperCase() ?? '?')
const gradient = computed(() => {

View File

@@ -56,7 +56,7 @@ describe('ChangeMemberRoleDialogContent', () => {
mockChangeMemberRole.mockResolvedValue(undefined)
})
it('shows promote copy and confirms with Make owner', async () => {
it('shows promote copy and confirms with Make admin', async () => {
const { user } = renderDialog('owner')
expect(

View File

@@ -64,6 +64,7 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { WORKSPACE_NAME_MAX_LENGTH } from '@/platform/workspace/workspaceConstants'
import { useDialogStore } from '@/stores/dialogStore'
const { onConfirm } = defineProps<{
@@ -80,7 +81,11 @@ const workspaceName = ref('')
const isValidName = computed(() => {
const name = workspaceName.value.trim()
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_'.,()&+]*$/
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
return (
name.length >= 1 &&
name.length <= WORKSPACE_NAME_MAX_LENGTH &&
safeNameRegex.test(name)
)
})
function onCancel() {

View File

@@ -58,6 +58,7 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { WORKSPACE_NAME_MAX_LENGTH } from '@/platform/workspace/workspaceConstants'
import { useDialogStore } from '@/stores/dialogStore'
const { t } = useI18n()
@@ -70,7 +71,11 @@ const newWorkspaceName = ref(workspaceStore.workspaceName)
const isValidName = computed(() => {
const name = newWorkspaceName.value.trim()
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_'.,()&+]*$/
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
return (
name.length >= 1 &&
name.length <= WORKSPACE_NAME_MAX_LENGTH &&
safeNameRegex.test(name)
)
})
function onCancel() {

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex w-full max-w-lg flex-col rounded-2xl border border-border-default bg-base-background"
class="flex w-132 max-w-full flex-col rounded-2xl border border-border-default bg-base-background"
>
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
@@ -35,7 +35,7 @@
:value="email"
:class="
cn(
'rounded-full',
'rounded-full bg-tertiary-background-hover',
!EMAIL_REGEX.test(email) && 'bg-danger/20 text-danger'
)
"

View File

@@ -0,0 +1,54 @@
<template>
<div
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">{{ title }}</h2>
<button
class="focus-visible:ring-secondary-foreground cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:ring-1 focus-visible:outline-none"
:aria-label="$t('g.close')"
@click="close"
>
<i class="pi pi-times size-4" />
</button>
</div>
<div class="p-4">
<p class="m-0 text-sm text-muted-foreground">{{ message }}</p>
</div>
<div class="flex items-center justify-end gap-2 p-4">
<Button variant="muted-textonly" @click="close">
{{ $t('g.close') }}
</Button>
<Button variant="secondary" size="lg" @click="requestMore">
{{ $t('workspacePanel.requestMore') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { useDialogStore } from '@/stores/dialogStore'
const { dialogKey, onRequestMore } = defineProps<{
dialogKey: string
title: string
message: string
onRequestMore: () => void
}>()
const dialogStore = useDialogStore()
function close() {
dialogStore.closeDialog({ key: dialogKey })
}
function requestMore() {
onRequestMore()
close()
}
</script>

View File

@@ -232,7 +232,7 @@ describe('TeamWorkspacesDialogContent', () => {
expect(findCreateButton(container)).toBeDisabled()
})
it('disables create button for name exceeding 50 characters', async () => {
it('disables create button for name exceeding the character limit', async () => {
const { container, user } = mountComponent()
const input = container.querySelector(
'#workspace-name-input'

View File

@@ -145,6 +145,7 @@ import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfil
import { useWorkspaceSwitch } from '@/platform/workspace/composables/useWorkspaceSwitch'
import { useWorkspaceTierLabel } from '@/platform/workspace/composables/useWorkspaceTierLabel'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { WORKSPACE_NAME_MAX_LENGTH } from '@/platform/workspace/workspaceConstants'
import { useDialogStore } from '@/stores/dialogStore'
const { onConfirm } = defineProps<{
@@ -178,7 +179,11 @@ const tierLabels = computed(
const isValidName = computed(() => {
const name = workspaceName.value.trim()
return name.length >= 1 && name.length <= 50 && SAFE_NAME_REGEX.test(name)
return (
name.length >= 1 &&
name.length <= WORKSPACE_NAME_MAX_LENGTH &&
SAFE_NAME_REGEX.test(name)
)
})
function onCancel() {

View File

@@ -0,0 +1,30 @@
<template>
<div class="@container flex min-h-0 flex-1 flex-col gap-4">
<!-- TODO(DES-503): models governance adds a Models sub-tab strip here;
prototype in PR #13487 -->
<div
class="flex w-full flex-col gap-3 @2xl:flex-row @2xl:items-center @2xl:gap-9"
>
<span class="min-w-0 flex-1 text-sm font-medium">
{{ $t('workspacePanel.allowlist.tabs.partnerNodes') }}
</span>
<SearchInput
v-model="searchQuery"
:placeholder="$t('workspacePanel.partnerNodes.searchPlaceholder')"
size="lg"
class="w-full @2xl:w-64"
/>
</div>
<PartnerNodesPanelContent :search="searchQuery" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import PartnerNodesPanelContent from '@/platform/workspace/components/dialogs/settings/PartnerNodesPanelContent.vue'
const searchQuery = ref('')
</script>

View File

@@ -0,0 +1,167 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import BillingStatusBanner from './BillingStatusBanner.vue'
const mockManageSubscription = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
const mockHandleResubscribe = vi.fn()
const mockBillingStatus = ref<'payment_failed' | null>(null)
const mockIsPaused = ref(true)
const mockIsActiveSubscription = ref(true)
const mockSubscription = ref<{
hasFunds: boolean
isCancelled: boolean
endDate?: string
}>({ hasFunds: true, isCancelled: false })
const mockRenewalDate = ref<string | null>(null)
const mockPermissions = ref({ canManageSubscription: true })
const mockIsInPersonalWorkspace = ref(false)
let mockActiveWorkspaceId: string | null = 'team-1'
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
billingStatus: mockBillingStatus,
isPaused: mockIsPaused,
isActiveSubscription: mockIsActiveSubscription,
subscription: mockSubscription,
renewalDate: mockRenewalDate,
manageSubscription: mockManageSubscription
})
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: mockPermissions,
isInPersonalWorkspace: mockIsInPersonalWorkspace
})
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
activeWorkspaceId: mockActiveWorkspaceId
})
}))
vi.mock('@/platform/workspace/composables/useResubscribe', () => ({
useResubscribe: () => ({
isResubscribing: ref(false),
handleResubscribe: mockHandleResubscribe
})
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderComponent() {
return render(BillingStatusBanner, { global: { plugins: [i18n] } })
}
describe('BillingStatusBanner', () => {
beforeEach(() => {
vi.clearAllMocks()
sessionStorage.clear()
mockBillingStatus.value = null
mockIsPaused.value = true
mockIsActiveSubscription.value = true
mockSubscription.value = { hasFunds: true, isCancelled: false }
mockRenewalDate.value = null
mockPermissions.value = { canManageSubscription: true }
mockIsInPersonalWorkspace.value = false
mockActiveWorkspaceId = 'team-1'
})
it('prioritizes a paused subscription and opens payment management', async () => {
const user = userEvent.setup()
mockBillingStatus.value = 'payment_failed'
renderComponent()
expect(screen.getByText('Subscription paused')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Update payment' }))
expect(mockManageSubscription).toHaveBeenCalledOnce()
})
it('shows paused members non-actionable guidance', () => {
mockPermissions.value = { canManageSubscription: false }
renderComponent()
expect(
screen.getByText(
"This workspace's subscription is paused. Your workspace admins need to update the payment method."
)
).toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('opens payment management for a failed payment', async () => {
const user = userEvent.setup()
mockIsPaused.value = false
mockBillingStatus.value = 'payment_failed'
mockRenewalDate.value = '2026-08-15T00:00:00Z'
renderComponent()
expect(screen.getByText('Payment declined')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Update payment' }))
expect(mockManageSubscription).toHaveBeenCalledOnce()
})
it('opens top-up and dismisses an out-of-credits banner', async () => {
const user = userEvent.setup()
mockIsPaused.value = false
mockSubscription.value = { hasFunds: false, isCancelled: false }
mockRenewalDate.value = '2026-08-15T00:00:00Z'
const { unmount } = renderComponent()
expect(screen.getByText('Out of credits')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Add credits' }))
expect(mockShowTopUpCreditsDialog).toHaveBeenCalledOnce()
await user.click(screen.getByRole('button', { name: 'Dismiss' }))
expect(screen.queryByRole('status')).not.toBeInTheDocument()
unmount()
renderComponent()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('reactivates a plan that is ending', async () => {
const user = userEvent.setup()
mockIsPaused.value = false
mockSubscription.value = {
hasFunds: true,
isCancelled: true,
endDate: '2026-08-31T00:00:00Z'
}
renderComponent()
expect(screen.getByText(/Your team plan ends on/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Reactivate plan' }))
expect(mockHandleResubscribe).toHaveBeenCalledOnce()
})
it('hides billing banners in personal workspaces', () => {
mockIsInPersonalWorkspace.value = true
renderComponent()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,188 @@
<template>
<div
v-if="banner"
role="status"
class="flex flex-col gap-3 rounded-2xl border border-interface-stroke/60 bg-base-background p-4 @2xl:flex-row @2xl:items-center @2xl:gap-2"
>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<i
:class="
cn(
'size-4 shrink-0',
// Muted circle for the calm plan-ending notice; amber triangle for
// every action-needed problem (paused, payment failed, out of credits).
banner.kind === 'ending'
? 'icon-[lucide--circle-alert] text-muted-foreground'
: 'icon-[lucide--triangle-alert] text-warning-background'
)
"
/>
<span class="text-sm text-base-foreground">{{ banner.title }}</span>
</div>
<p class="m-0 pl-6 text-sm text-muted-foreground">{{ banner.body }}</p>
</div>
<div
v-if="banner.showAction"
class="flex shrink-0 flex-wrap items-center gap-2 pl-6 @2xl:pl-0"
>
<slot name="actions" />
<template v-if="banner.kind === 'outOfCredits'">
<Button variant="textonly" size="lg" @click="dismiss">
{{ $t('workspacePanel.billingStatus.outOfCredits.dismiss') }}
</Button>
<Button variant="secondary" size="lg" @click="handleAddCredits">
{{ $t('workspacePanel.billingStatus.outOfCredits.addCredits') }}
</Button>
</template>
<Button
v-else-if="banner.kind === 'ending'"
variant="secondary"
size="lg"
:loading="isResubscribing"
@click="handleResubscribe"
>
{{ $t('workspacePanel.billingStatus.ending.reactivate') }}
</Button>
<Button v-else variant="inverted" size="lg" @click="manageSubscription">
{{ $t('workspacePanel.billingStatus.updatePayment') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useSessionStorage } from '@vueuse/core'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useResubscribe } from '@/platform/workspace/composables/useResubscribe'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
import { useDialogService } from '@/services/dialogService'
import { cn } from '@comfyorg/tailwind-utils'
const { t, d } = useI18n()
const {
billingStatus,
isPaused,
isActiveSubscription,
subscription,
renewalDate,
manageSubscription
} = useBillingContext()
const { permissions, isInPersonalWorkspace } = useWorkspaceUI()
const dialogService = useDialogService()
const { isResubscribing, handleResubscribe } = useResubscribe()
const workspaceStore = useTeamWorkspaceStore()
const dismissedWorkspaceIds = useSessionStorage<string[]>(
WORKSPACE_STORAGE_KEYS.DISMISSED_BILLING_BANNERS,
[]
)
const canManage = computed(() => permissions.value.canManageSubscription)
const cycleResetDate = computed(() => {
const raw = renewalDate.value
return raw ? d(new Date(raw), { month: 'short', day: 'numeric' }) : ''
})
const planEndDate = computed(() => {
const raw = subscription.value?.endDate
return raw
? d(new Date(raw), { year: 'numeric', month: 'long', day: 'numeric' })
: ''
})
// Out of credits: an active, non-paused team that has exhausted its balance.
// Paused takes over this slot (see priority below). Dismissible for the session.
const dismissed = computed(() => {
const workspaceId = workspaceStore.activeWorkspaceId
return !!workspaceId && dismissedWorkspaceIds.value.includes(workspaceId)
})
const isOutOfCredits = computed(
() =>
isActiveSubscription.value &&
!isPaused.value &&
subscription.value?.hasFunds === false
)
// A cancelled-but-still-active plan is winding down to its end date. Unlike the
// states above it's a calm, owner-initiated notice (not a problem), so it sits
// last and reads with the muted circle icon and a low-key secondary action.
const isEnding = computed(
() =>
isActiveSubscription.value &&
!isPaused.value &&
(subscription.value?.isCancelled ?? false) &&
planEndDate.value !== ''
)
// One status banner slot across every workspace tab, in priority order: paused →
// payment-failure warning → out of credits → plan ending. All owner/admin-only
// (members can't act on any of them).
const banner = computed(() => {
if (isInPersonalWorkspace.value) return null
if (isPaused.value) {
return {
kind: 'paused' as const,
title: t('workspacePanel.billingStatus.paused.title'),
body: canManage.value
? t('workspacePanel.billingStatus.paused.body')
: t('workspacePanel.billingStatus.paused.memberBody'),
showAction: canManage.value
}
}
if (billingStatus.value === 'payment_failed' && canManage.value) {
return {
kind: 'warning' as const,
title: t('workspacePanel.billingStatus.warning.title'),
body: t('workspacePanel.billingStatus.warning.body', {
date: cycleResetDate.value
}),
showAction: true
}
}
if (isOutOfCredits.value && canManage.value && !dismissed.value) {
return {
kind: 'outOfCredits' as const,
title: t('workspacePanel.billingStatus.outOfCredits.title'),
body: cycleResetDate.value
? t('workspacePanel.billingStatus.outOfCredits.body', {
date: cycleResetDate.value
})
: t('workspacePanel.billingStatus.outOfCredits.bodyNoDate'),
showAction: true
}
}
if (isEnding.value && canManage.value) {
return {
kind: 'ending' as const,
title: t('workspacePanel.billingStatus.ending.title', {
date: planEndDate.value
}),
body: t('workspacePanel.billingStatus.ending.body'),
showAction: true
}
}
return null
})
function dismiss() {
const workspaceId = workspaceStore.activeWorkspaceId
if (!workspaceId || dismissed.value) return
dismissedWorkspaceIds.value = [...dismissedWorkspaceIds.value, workspaceId]
}
function handleAddCredits() {
void dialogService.showTopUpCreditsDialog()
}
</script>

View File

@@ -1,91 +0,0 @@
<template>
<div
:data-testid="`member-row-${member.id}`"
:class="
cn(
'grid w-full items-center rounded-lg p-2',
isSingleSeatPlan ? 'grid-cols-1' : gridCols,
striped && 'bg-secondary-background/50'
)
"
>
<div class="flex items-center gap-3">
<UserAvatar
class="size-8"
:photo-url="isCurrentUser ? photoUrl : undefined"
:pt:icon:class="{ 'text-xl!': !isCurrentUser || !photoUrl }"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-sm text-base-foreground">
{{ member.name }}
<span v-if="isCurrentUser" class="text-muted-foreground">
({{ $t('g.you') }})
</span>
</span>
<span class="text-sm text-muted-foreground">
{{ member.email }}
</span>
</div>
</div>
<span
v-if="showRoleColumn && !isSingleSeatPlan"
class="text-right text-sm text-muted-foreground"
>
{{
member.role === 'owner'
? $t('workspaceSwitcher.roleOwner')
: $t('workspaceSwitcher.roleMember')
}}
</span>
<div
v-if="canManageMembers && !isSingleSeatPlan"
class="flex items-center justify-end"
>
<DropdownMenu
v-if="!isCurrentUser && !isOriginalOwner"
:entries="menuItems"
>
<template #button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
>
<i class="pi pi-ellipsis-h" />
</Button>
</template>
</DropdownMenu>
</div>
</div>
</template>
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@comfyorg/tailwind-utils'
const {
showRoleColumn = false,
canManageMembers = false,
isSingleSeatPlan = false,
isOriginalOwner = false,
striped = false,
menuItems = []
} = defineProps<{
member: WorkspaceMember
isCurrentUser: boolean
photoUrl?: string
gridCols: string
showRoleColumn?: boolean
canManageMembers?: boolean
isSingleSeatPlan?: boolean
isOriginalOwner?: boolean
striped?: boolean
menuItems?: MenuItem[]
}>()
</script>

View File

@@ -0,0 +1,117 @@
<template>
<TableRow
:data-testid="`member-row-${member.id}`"
class="group hover:bg-transparent [&:last-child>td]:border-b-0 [&>td]:border-b [&>td]:border-interface-stroke/20"
>
<TableCell>
<div class="flex items-center gap-3">
<span
class="flex size-8 shrink-0 items-center justify-center rounded-full"
:style="{
backgroundColor: userBadgeColor(member.name || member.email)
}"
>
<span class="text-sm font-bold text-base-foreground">
{{ initial }}
</span>
</span>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-sm text-base-foreground">
{{ member.name }}
<span v-if="isCurrentUser" class="text-muted-foreground">
({{ $t('g.you') }})
</span>
</span>
<span class="truncate text-sm text-muted-foreground">
{{ member.email }}
</span>
</div>
</div>
</TableCell>
<TableCell class="text-sm text-muted-foreground">
{{ $t(roleLabelKey(member.role, isOriginalOwner)) }}
</TableCell>
<TableCell v-if="canManageMembers" class="text-sm text-muted-foreground">
{{ lastActivityLabel }}
</TableCell>
<TableCell
v-if="canManageMembers"
class="text-right text-sm text-muted-foreground tabular-nums"
>
{{ creditsLabel }}
</TableCell>
<TableCell v-if="canManageMembers" class="text-right" @click.stop>
<DropdownMenu
v-if="showMenu"
:entries="menuItems"
:modal="false"
content-class="min-w-44"
>
<template #button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
>
<i class="pi pi-ellipsis-h" />
</Button>
</template>
</DropdownMenu>
</TableCell>
</TableRow>
</template>
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import Button from '@/components/ui/button/Button.vue'
import TableCell from '@/components/ui/table/TableCell.vue'
import TableRow from '@/components/ui/table/TableRow.vue'
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
import { userBadgeColor } from '@/platform/workspace/utils/badgeColor'
import { roleLabelKey } from '@/platform/workspace/utils/roleLabels'
import { formatRelativeTime } from '@/platform/workspace/utils/relativeTime'
const {
member,
isCurrentUser,
canManageMembers = false,
isOriginalOwner = false,
menuItems = []
} = defineProps<{
member: WorkspaceMember
isCurrentUser: boolean
canManageMembers?: boolean
isOriginalOwner?: boolean
menuItems?: MenuItem[]
}>()
const { t } = useI18n()
const initial = computed(() =>
(member.name || member.email).charAt(0).toUpperCase()
)
// The creator and the current user can't be managed from their own row.
const showMenu = computed(
() => canManageMembers && !isCurrentUser && !isOriginalOwner
)
const lastActivityLabel = computed(() => {
if (!member.lastActivity) return t('workspacePanel.members.activity.never')
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)
})
})
const creditsLabel = computed(() =>
(member.creditsUsedThisMonth ?? 0).toLocaleString()
)
</script>

View File

@@ -1,8 +1,7 @@
import { render, screen, within } from '@testing-library/vue'
import { render, screen, waitFor, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Slots } from 'vue'
import { computed, h, ref } from 'vue'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import MembersPanelContent from './MembersPanelContent.vue'
@@ -18,6 +17,7 @@ const mockMemberMenuItems = vi.fn(() => [])
const mockShowTeamPlans = vi.fn()
const mockToggleSort = vi.fn()
const mockHandleInviteMember = vi.fn()
const mockFetchBalance = vi.fn()
const {
mockMembers,
@@ -27,13 +27,14 @@ const {
mockFilteredPendingInvites,
mockIsPersonalWorkspace,
mockIsOnTeamPlan,
mockHasMultipleMembers,
mockShowSearch,
mockShowViewTabs,
mockShowInviteButton,
mockIsInviteDisabled,
mockActiveView,
mockSearchQuery,
mockSortField,
mockSortDirection,
mockPermissions,
mockUiConfig
} = vi.hoisted(() => {
@@ -44,7 +45,6 @@ const {
mockMembers: ref<WorkspaceMember[]>([]),
mockPendingInvites: ref<PendingInvite[]>([]),
mockOriginalOwnerId: ref<string | null>(null),
mockHasMultipleMembers: ref(true),
mockShowSearch: ref(true),
mockShowViewTabs: ref(true),
mockShowInviteButton: ref(true),
@@ -55,39 +55,36 @@ const {
mockIsOnTeamPlan: ref(true),
mockActiveView: ref<'active' | 'pending'>('active'),
mockSearchQuery: ref(''),
mockSortField: ref('role'),
mockSortDirection: ref('desc'),
mockPermissions: ref({
canViewOtherMembers: true,
canViewPendingInvites: true,
canInviteMembers: true,
canManageInvites: true,
canManageMembers: true,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true,
canTopUp: true
canManageMembers: true
}),
mockUiConfig: ref({
showMembersList: true,
showPendingTab: true,
showSearch: true,
showRoleColumn: true,
membersGridCols: 'grid-cols-[50%_40%_10%]',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-[50%_40%_10%]',
showEditWorkspaceMenuItem: true,
workspaceMenuAction: 'delete' as 'leave' | 'delete' | null,
workspaceMenuDisabledTooltip: null as string | null
showSearch: true
})
}
})
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({ isPaused: computed(() => false) })
}))
vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
useMembersPanel: () => ({
searchQuery: mockSearchQuery,
activeView: mockActiveView,
maxSeats: computed(() => 20),
sortField: mockSortField,
sortDirection: mockSortDirection,
maxSeats: computed(() => 50),
memberCount: computed(() => mockMembers.value.length),
isOnTeamPlan: mockIsOnTeamPlan,
hasMultipleMembers: mockHasMultipleMembers,
hasLapsedTeamPlan: computed(() => false),
showSearch: mockShowSearch,
showViewTabs: mockShowViewTabs,
showInviteButton: mockShowInviteButton,
@@ -104,7 +101,6 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
})),
filteredMembers: mockFilteredMembers,
filteredPendingInvites: mockFilteredPendingInvites,
memberMenuItems: mockMemberMenuItems,
memberMenus: computed(
() =>
new Map(
@@ -112,28 +108,21 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
)
),
isPersonalWorkspace: mockIsPersonalWorkspace,
members: mockMembers,
pendingInvites: mockPendingInvites,
permissions: mockPermissions,
uiConfig: mockUiConfig,
userPhotoUrl: ref(null),
fetchBalance: mockFetchBalance,
isCurrentUser: (m: WorkspaceMember) =>
m.email.toLowerCase() === 'owner@example.com',
isOriginalOwner: (m: WorkspaceMember) => m.id === mockOriginalOwnerId.value,
toggleSort: mockToggleSort,
showTeamPlans: mockShowTeamPlans,
handleResendInvite: mockHandleResendInvite,
handleRevokeInvite: mockHandleRevokeInvite,
handleRemoveMember: vi.fn(),
handleChangeRole: vi.fn()
handleRevokeInvite: mockHandleRevokeInvite
})
}))
vi.mock('@/components/button/MoreButton.vue', () => ({
default: (_: unknown, { slots }: { slots: Slots }) =>
h('div', slots.default?.({ close: () => {} }))
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -157,6 +146,15 @@ const SearchInputStub = {
emits: ['update:modelValue']
}
// Render the trigger slot (carries the g.moreOptions button) plus each entry as
// a flat button, so menu items are assertable without opening a real overlay.
const DropdownMenuStub = {
name: 'DropdownMenu',
props: ['entries', 'modal', 'contentClass'],
template:
'<div><slot name="button" /><button v-for="e in (entries || [])" :key="e.label" :aria-label="e.label" @click="e.command && e.command()">{{ e.label }}</button></div>'
}
function renderComponent() {
return render(MembersPanelContent, {
global: {
@@ -164,8 +162,9 @@ function renderComponent() {
stubs: {
Button: ButtonStub,
SearchInput: SearchInputStub,
DropdownMenu: DropdownMenuStub,
UserAvatar: true,
WorkspaceMenuButton: true
BillingStatusBanner: true
},
directives: { tooltip: () => {} }
}
@@ -199,6 +198,7 @@ function createInvite(overrides: Partial<PendingInvite> = {}): PendingInvite {
describe('MembersPanelContent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchBalance.mockResolvedValue(undefined)
mockMemberMenuItems.mockReturnValue([])
mockMembers.value = []
mockPendingInvites.value = []
@@ -207,7 +207,6 @@ describe('MembersPanelContent', () => {
mockFilteredPendingInvites.value = []
mockIsPersonalWorkspace.value = false
mockIsOnTeamPlan.value = true
mockHasMultipleMembers.value = true
mockShowSearch.value = true
mockShowViewTabs.value = true
mockShowInviteButton.value = true
@@ -215,41 +214,41 @@ describe('MembersPanelContent', () => {
mockActiveView.value = 'active'
mockSearchQuery.value = ''
mockPermissions.value = {
canViewOtherMembers: true,
canViewPendingInvites: true,
canInviteMembers: true,
canManageInvites: true,
canManageMembers: true,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true,
canTopUp: true
canManageMembers: true
}
mockUiConfig.value = {
showMembersList: true,
showPendingTab: true,
showSearch: true,
showRoleColumn: true,
membersGridCols: 'grid-cols-[50%_40%_10%]',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-[50%_40%_10%]',
showEditWorkspaceMenuItem: true,
workspaceMenuAction: 'delete',
workspaceMenuDisabledTooltip: null
showSearch: true
}
})
it('handles billing balance load failures', async () => {
const error = new Error('network failure')
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockFetchBalance.mockRejectedValue(error)
renderComponent()
await waitFor(() =>
expect(consoleError).toHaveBeenCalledWith(
'Failed to load workspace billing balance',
error
)
)
consoleError.mockRestore()
})
describe('personal workspace', () => {
beforeEach(() => {
mockIsPersonalWorkspace.value = true
mockIsOnTeamPlan.value = false
mockHasMultipleMembers.value = false
mockShowSearch.value = false
mockShowViewTabs.value = false
mockIsInviteDisabled.value = true
mockUiConfig.value.showMembersList = false
mockUiConfig.value.showSearch = false
mockUiConfig.value.showPendingTab = false
})
it('shows the upsell banner below the members card', () => {
@@ -275,7 +274,7 @@ describe('MembersPanelContent', () => {
})
})
describe('team workspace - member list', () => {
describe('team workspace - member table', () => {
it('shows the Role column header and member roles', () => {
mockFilteredMembers.value = [
createMember({ role: 'owner', email: 'boss@test.com' }),
@@ -285,18 +284,47 @@ describe('MembersPanelContent', () => {
expect(
screen.getByText('workspacePanel.members.columns.role')
).toBeTruthy()
expect(screen.getByText('workspaceSwitcher.roleOwner')).toBeTruthy()
expect(screen.getByText('workspaceSwitcher.roleAdmin')).toBeTruthy()
expect(screen.getByText('workspaceSwitcher.roleMember')).toBeTruthy()
})
it('shows the Last activity and Credits columns', () => {
mockFilteredMembers.value = [createMember()]
renderComponent()
expect(
screen.getByText('workspacePanel.members.columns.lastActivity')
).toBeTruthy()
expect(
screen.getByText('workspacePanel.members.columns.creditsUsed')
).toBeTruthy()
})
it('renders the monthly credits for a member', () => {
mockFilteredMembers.value = [createMember({ creditsUsedThisMonth: 6532 })]
renderComponent()
expect(screen.getByText('6,532')).toBeTruthy()
})
it('labels the original owner as Owner and other owners as Admin', () => {
mockOriginalOwnerId.value = 'creator-1'
mockFilteredMembers.value = [
createMember({
id: 'creator-1',
email: 'creator@test.com',
role: 'owner',
isOriginalOwner: true
}),
createMember({ id: '2', email: 'admin@test.com', role: 'owner' })
]
renderComponent()
expect(screen.getByText('workspaceSwitcher.roleOwner')).toBeTruthy()
expect(screen.getByText('workspaceSwitcher.roleAdmin')).toBeTruthy()
})
it('renders filtered members', () => {
mockFilteredMembers.value = [
createMember({ name: 'Alice', email: 'alice@test.com' }),
createMember({
id: '2',
name: 'Bob',
email: 'bob@test.com'
})
createMember({ id: '2', name: 'Bob', email: 'bob@test.com' })
]
renderComponent()
expect(screen.getByText('Alice')).toBeTruthy()
@@ -388,34 +416,20 @@ describe('MembersPanelContent', () => {
describe('member role', () => {
beforeEach(() => {
mockPermissions.value = {
canViewOtherMembers: true,
canViewPendingInvites: false,
canViewPendingInvites: true,
canInviteMembers: false,
canManageInvites: false,
canManageMembers: false,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: false,
canTopUp: false
canManageMembers: false
}
mockUiConfig.value.showPendingTab = false
mockUiConfig.value.showPendingTab = true
})
it('hides the pending tab button', () => {
it('shows the pending tab button (view-only)', () => {
mockPendingInvites.value = [createInvite()]
renderComponent()
expect(
screen.queryByText(/workspacePanel\.members\.tabs\.pendingCount/)
).toBeNull()
})
it('does not show the pending invites header', () => {
mockActiveView.value = 'pending'
mockPendingInvites.value = [createInvite()]
renderComponent()
expect(
screen.queryByText(/workspacePanel\.members\.pendingInvitesCount/)
).toBeNull()
screen.getByText(/workspacePanel\.members\.tabs\.pendingCount/)
).toBeTruthy()
})
it('shows no action menus on member rows', () => {
@@ -451,15 +465,6 @@ describe('MembersPanelContent', () => {
).toBeNull()
})
it('opens subscription dialog on upgrade click', async () => {
renderComponent()
const upgradeBtn = screen.getByRole('button', {
name: /workspacePanel\.members\.upgradeToTeam/
})
await userEvent.click(upgradeBtn)
expect(mockShowTeamPlans).toHaveBeenCalled()
})
it('hides search input', () => {
renderComponent()
expect(screen.queryByRole('textbox')).toBeNull()
@@ -472,17 +477,15 @@ describe('MembersPanelContent', () => {
})
describe('contact us footer', () => {
it('opens discord in a new tab for team workspaces on a team plan', async () => {
it('opens the team-plan request form in a new tab for team workspaces on a team plan', async () => {
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
renderComponent()
expect(
screen.getByText('workspacePanel.members.needMoreMembers')
).toBeTruthy()
expect(screen.getByText(/needMoreMembers/)).toBeTruthy()
await userEvent.click(
screen.getByText('workspacePanel.members.contactUs')
)
expect(openSpy).toHaveBeenCalledWith(
'https://www.comfy.org/discord',
'https://comfy-org.portal.usepylon.com/forms/team-plan-requests',
'_blank',
'noopener,noreferrer'
)
@@ -496,16 +499,12 @@ describe('MembersPanelContent', () => {
})
})
describe('member count display', () => {
it('shows member count header for team workspace', () => {
mockFilteredMembers.value = [
createMember({ id: '1' }),
createMember({ id: '2' })
]
mockMembers.value = mockFilteredMembers.value
describe('member count tab', () => {
it('shows the members count tab for team workspace', () => {
mockMembers.value = [createMember({ id: '1' }), createMember({ id: '2' })]
renderComponent()
expect(
screen.getByText(/workspacePanel\.members\.membersCount/)
screen.getByText(/workspacePanel\.members\.tabs\.membersCount/)
).toBeTruthy()
})
})
@@ -540,10 +539,7 @@ describe('MembersPanelContent', () => {
mockShowViewTabs.value = false
renderComponent()
expect(
screen.queryByText('workspacePanel.members.tabs.active')
).toBeNull()
expect(
screen.queryByText('workspacePanel.members.columns.role')
screen.queryByText(/workspacePanel\.members\.tabs\.pendingCount/)
).toBeNull()
})
})

View File

@@ -1,209 +1,217 @@
<template>
<div class="grow overflow-auto pt-6">
<div class="@container flex min-h-0 flex-1 flex-col gap-4 pb-6">
<!-- Header: tabs (left) + search / invite (right), above the card -->
<div
class="border-inter flex size-full flex-col gap-2 rounded-2xl border border-interface-stroke p-6"
class="flex w-full flex-col gap-3 @2xl:flex-row @2xl:items-center @2xl:gap-9"
>
<!-- Section Header -->
<div class="flex w-full items-center gap-9">
<div class="flex min-w-0 flex-1 items-baseline gap-2">
<span class="text-base font-semibold text-base-foreground">
<template v-if="activeView === 'active'">
<template v-if="isOnTeamPlan && !isPersonalWorkspace">
{{
$t('workspacePanel.members.membersCount', {
count: members.length,
maxSeats: maxSeats
})
}}
</template>
<template v-else>
{{ $t('workspacePanel.members.header') }}
</template>
</template>
<template v-else-if="permissions.canViewPendingInvites">
{{
$t(
'workspacePanel.members.pendingInvitesCount',
pendingInvites.length
)
}}
</template>
</span>
</div>
<div class="flex items-center gap-2">
<SearchInput
v-if="showSearch"
v-model="searchQuery"
:placeholder="$t('workspacePanel.members.searchPlaceholder')"
size="lg"
class="w-64"
/>
<div class="flex min-w-0 flex-1 items-center gap-2">
<template v-if="showViewTabs">
<Button
v-if="showInviteButton"
v-tooltip="
inviteTooltip
? { value: inviteTooltip, showDelay: 0 }
: { value: $t('workspacePanel.inviteMember'), showDelay: 300 }
"
variant="secondary"
:variant="activeView === 'active' ? 'secondary' : 'muted-textonly'"
size="lg"
:disabled="isInviteDisabled"
:aria-label="$t('workspacePanel.inviteMember')"
@click="handleInviteMember"
@click="activeView = 'active'"
>
{{ $t('workspacePanel.invite') }}
<i class="pi pi-plus text-sm" />
{{ $t('workspacePanel.members.tabs.membersCount', memberCount) }}
</Button>
<WorkspaceMenuButton v-if="permissions.canAccessWorkspaceMenu" />
</div>
<Button
v-if="uiConfig.showPendingTab"
:variant="activeView === 'pending' ? 'secondary' : 'muted-textonly'"
size="lg"
@click="activeView = 'pending'"
>
{{
pendingInvites.length > 0
? $t(
'workspacePanel.members.tabs.pendingCount',
pendingInvites.length
)
: $t('workspacePanel.members.tabs.pending')
}}
</Button>
</template>
<span v-else class="text-base font-normal text-base-foreground">
{{ $t('workspacePanel.members.tabs.membersCount', memberCount) }}
</span>
</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 && showViewTabs"
:class="
cn(
'grid w-full items-center py-2',
activeView === 'pending'
? uiConfig.pendingGridCols
: uiConfig.headerGridCols
)
<div class="flex w-full items-center gap-2 @2xl:w-auto">
<SearchInput
v-if="showSearch"
v-model="searchQuery"
:placeholder="$t('workspacePanel.members.searchPlaceholder')"
size="lg"
class="min-w-0 flex-1 @2xl:w-64 @2xl:flex-none"
/>
<Button
v-if="showInviteButton"
v-tooltip="
inviteTooltip
? { value: inviteTooltip, showDelay: 0 }
: { value: $t('workspacePanel.inviteMember'), showDelay: 300 }
"
variant="secondary"
size="lg"
class="shrink-0"
:disabled="isInviteDisabled"
:aria-label="$t('workspacePanel.inviteMember')"
@click="handleInviteMember"
>
<!-- 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.invite') }}
<i class="icon-[lucide--plus] size-4" />
</Button>
</div>
</div>
<BillingStatusBanner />
<!-- Card: fills height, table scrolls inside -->
<div
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-interface-stroke/60"
>
<Table v-if="activeView === 'active'" class="min-h-0 flex-1 px-4">
<TableHeader class="sticky top-0 z-10 bg-base-background">
<TableRow
class="hover:bg-transparent [&>th]:h-14 [&>th]:border-b [&>th]:border-interface-stroke/60"
>
<TableHead :aria-sort="ariaSort('email')">
<button :class="sortHeaderClass" @click="toggleSort('email')">
{{ $t('workspacePanel.members.columns.email') }}
<i :class="sortIcon('email')" />
</button>
</TableHead>
<TableHead
:class="permissions.canManageMembers ? 'w-40' : undefined"
:aria-sort="ariaSort('role')"
>
{{ $t('workspacePanel.members.tabs.active') }}
</Button>
<Button
v-if="uiConfig.showPendingTab"
:variant="
activeView === 'pending' ? 'secondary' : 'muted-textonly'
"
size="md"
@click="activeView = 'pending'"
<button :class="sortHeaderClass" @click="toggleSort('role')">
{{ $t('workspacePanel.members.columns.role') }}
<i :class="sortIcon('role')" />
</button>
</TableHead>
<TableHead
v-if="permissions.canManageMembers"
class="w-40"
:aria-sort="ariaSort('lastActivity')"
>
{{
$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')"
<button
:class="sortHeaderClass"
@click="toggleSort('lastActivity')"
>
{{ $t('workspacePanel.members.columns.lastActivity') }}
<i :class="sortIcon('lastActivity')" />
</button>
</TableHead>
<TableHead
v-if="permissions.canManageMembers"
class="w-64"
:aria-sort="ariaSort('credits')"
>
{{ $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>
<button
:class="cn(sortHeaderClass, 'ml-auto')"
@click="toggleSort('credits')"
>
<i class="icon-[lucide--coins] size-4" />
{{ $t('workspacePanel.members.columns.creditsUsed') }}
<i :class="sortIcon('credits')" />
</button>
</TableHead>
<TableHead v-if="permissions.canManageMembers" class="w-12" />
</TableRow>
</TableHeader>
<TableBody>
<MemberTableRow
v-if="isPersonalWorkspace"
:member="personalWorkspaceMember"
:is-current-user="true"
/>
<template v-else>
<Button
variant="muted-textonly"
size="sm"
class="justify-end"
@click="toggleSort('role')"
>
{{ $t('workspacePanel.members.columns.role') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<!-- Empty cell for action column header (OWNER only) -->
<div v-if="permissions.canManageMembers" />
<MemberTableRow
v-for="member in filteredMembers"
:key="member.id"
:member="member"
:is-current-user="isCurrentUser(member)"
:can-manage-members="permissions.canManageMembers"
:is-original-owner="isOriginalOwner(member)"
:menu-items="memberMenus.get(member.id)"
/>
</template>
</div>
</TableBody>
</Table>
<!-- Members List -->
<div class="min-h-0 flex-1 overflow-y-auto">
<!-- Active Members -->
<template v-if="activeView === 'active'">
<!-- Personal Workspace: show only current user -->
<template v-if="isPersonalWorkspace">
<MemberListItem
:member="personalWorkspaceMember"
:is-current-user="true"
:photo-url="userPhotoUrl ?? undefined"
:grid-cols="uiConfig.membersGridCols"
/>
</template>
<!-- Team Workspace: sorted list -->
<template v-else>
<MemberListItem
v-for="(member, index) in filteredMembers"
:key="member.id"
:member="member"
:is-current-user="isCurrentUser(member)"
:photo-url="
isCurrentUser(member)
? (userPhotoUrl ?? undefined)
: undefined
"
:grid-cols="uiConfig.membersGridCols"
:show-role-column="
uiConfig.showRoleColumn && hasMultipleMembers
"
:can-manage-members="permissions.canManageMembers"
:is-single-seat-plan="!isOnTeamPlan"
:is-original-owner="isOriginalOwner(member)"
:striped="index % 2 === 1"
:menu-items="memberMenus.get(member.id)"
/>
</template>
</template>
<!-- Pending Invites -->
<PendingInvitesList
v-if="activeView === 'pending'"
:invites="filteredPendingInvites"
:grid-cols="uiConfig.pendingGridCols"
<Table v-else class="min-h-0 flex-1 px-4">
<TableHeader class="sticky top-0 z-10 bg-base-background">
<TableRow
class="hover:bg-transparent [&>th]:h-14 [&>th]:border-b [&>th]:border-interface-stroke/60"
>
<TableHead>
<span :class="sortHeaderClass">
{{ $t('workspacePanel.members.columns.email') }}
</span>
</TableHead>
<TableHead class="w-40" :aria-sort="ariaSort('inviteDate')">
<button
:class="sortHeaderClass"
@click="toggleSort('inviteDate')"
>
{{ $t('workspacePanel.members.columns.inviteDate') }}
<i :class="sortIcon('inviteDate')" />
</button>
</TableHead>
<TableHead class="w-40" :aria-sort="ariaSort('expiryDate')">
<button
:class="sortHeaderClass"
@click="toggleSort('expiryDate')"
>
{{ $t('workspacePanel.members.columns.expiryDate') }}
<i :class="sortIcon('expiryDate')" />
</button>
</TableHead>
<TableHead v-if="permissions.canManageInvites" class="w-12" />
</TableRow>
</TableHeader>
<TableBody>
<PendingInviteRow
v-for="invite in filteredPendingInvites"
:key="invite.id"
:invite="invite"
:can-manage="permissions.canManageInvites"
@resend="handleResendInvite"
@revoke="handleRevokeInvite"
/>
</div>
</div>
<TableRow
v-if="filteredPendingInvites.length === 0"
class="hover:bg-transparent"
>
<TableCell
:colspan="permissions.canManageInvites ? 4 : 3"
class="py-6 text-center text-sm text-muted-foreground"
>
{{ $t('workspacePanel.members.noInvites') }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- Upsell Banner -->
<MemberUpsellBanner
v-if="!isOnTeamPlan"
:reactivate="hasLapsedTeamPlan"
@show-plans="showTeamPlans()"
/>
<!-- Need More Members Footer -->
<div
v-if="isOnTeamPlan && !isPersonalWorkspace"
class="flex items-center pt-2"
class="flex h-8 items-center"
>
<p class="text-sm text-muted-foreground">
{{ $t('workspacePanel.members.needMoreMembers') }}
{{ membersUsageLabel }}
<template v-if="permissions.canInviteMembers">
{{ $t('workspacePanel.members.needMoreMembers') }}
</template>
</p>
<Button
v-if="permissions.canInviteMembers"
variant="muted-textonly"
size="sm"
class="text-base-foreground"
size="md"
class="text-sm text-base-foreground"
@click="handleContactUs"
>
{{ $t('workspacePanel.members.contactUs') }}
@@ -215,21 +223,31 @@
<script setup lang="ts">
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.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 { useExternalLink } from '@/composables/useExternalLink'
import MemberListItem from '@/platform/workspace/components/dialogs/settings/MemberListItem.vue'
import MemberTableRow from '@/platform/workspace/components/dialogs/settings/MemberTableRow.vue'
import MemberUpsellBanner from '@/platform/workspace/components/dialogs/settings/MemberUpsellBanner.vue'
import PendingInvitesList from '@/platform/workspace/components/dialogs/settings/PendingInvitesList.vue'
import WorkspaceMenuButton from '@/platform/workspace/components/dialogs/settings/WorkspaceMenuButton.vue'
import PendingInviteRow from '@/platform/workspace/components/dialogs/settings/PendingInviteRow.vue'
import { useMembersPanel } from '@/platform/workspace/composables/useMembersPanel'
import { cn } from '@comfyorg/tailwind-utils'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
const {
searchQuery,
activeView,
sortField,
sortDirection,
maxSeats,
memberCount,
isOnTeamPlan,
hasLapsedTeamPlan,
hasMultipleMembers,
showSearch,
showViewTabs,
showInviteButton,
@@ -241,11 +259,10 @@ const {
filteredPendingInvites,
memberMenus,
isPersonalWorkspace,
members,
pendingInvites,
permissions,
uiConfig,
userPhotoUrl,
fetchBalance,
isCurrentUser,
isOriginalOwner,
toggleSort,
@@ -255,8 +272,40 @@ const {
} = useMembersPanel()
const { staticUrls } = useExternalLink()
const { t } = useI18n()
// Owners get "Need more members?" after the count, where the period reads as a
// separator; members see just the count, so drop the trailing period.
const membersUsageLabel = computed(() => {
const label = t('workspacePanel.members.membersUsage', {
count: memberCount.value,
max: maxSeats.value
})
return permissions.value.canInviteMembers ? label : label.replace(/\.$/, '')
})
const sortHeaderClass =
'flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left font-[inherit] text-sm text-muted-foreground'
function sortIcon(field: string) {
if (sortField.value !== field) return 'icon-[lucide--chevrons-up-down] size-3'
return sortDirection.value === 'asc'
? 'icon-[lucide--chevron-up] size-3'
: 'icon-[lucide--chevron-down] size-3'
}
function ariaSort(field: string): 'ascending' | 'descending' | 'none' {
if (sortField.value !== field) return 'none'
return sortDirection.value === 'asc' ? 'ascending' : 'descending'
}
function handleContactUs() {
window.open(staticUrls.discord, '_blank', 'noopener,noreferrer')
window.open(staticUrls.teamPlanRequests, '_blank', 'noopener,noreferrer')
}
onMounted(() => {
void fetchBalance().catch((error: unknown) => {
console.error('Failed to load workspace billing balance', error)
})
})
</script>

View File

@@ -0,0 +1,25 @@
<template>
<span
class="flex size-5 shrink-0 items-center justify-center rounded-full bg-secondary-background-hover"
>
<i :class="cn(getProviderIcon(partner), 'size-3')" :style="iconStyle" />
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { getProviderBorderStyle, getProviderIcon } from '@/utils/categoryUtil'
import { cn } from '@comfyorg/tailwind-utils'
const { partner } = defineProps<{ partner: string }>()
// Monotone provider glyphs (Anthropic, BFL, …) render in currentColor, so tint
// them with the brand color. Multi-color brands (ByteDance, Kling, Gemini, …)
// ship full-color icons — identified by a gradient brand color — and must be
// left untouched or the tint replaces their artwork.
const iconStyle = computed(() => {
const style = getProviderBorderStyle(partner)
return style.includes('gradient') ? undefined : { color: style }
})
</script>

View File

@@ -0,0 +1,188 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import PartnerNodesPanelContent from './PartnerNodesPanelContent.vue'
vi.mock(
'@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue',
() => ({ default: { template: '<div />' } })
)
vi.mock(
'@/platform/workspace/components/dialogs/settings/PartnerBadge.vue',
() => ({ default: { template: '<span />' } })
)
const mock = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { computed, ref } = require('vue') as typeof import('vue')
const enabledNode = {
id: 'a',
name: 'Zeta Node',
partner: 'BFL',
last_modified: '2026-01-02T00:00:00Z',
enabled: true
}
const hiddenDisabledNode = {
id: 'b',
name: 'Hidden Node',
partner: 'BFL',
last_modified: '2026-01-01T00:00:00Z',
enabled: false
}
const nodes = ref([enabledNode, hiddenDisabledNode])
const filteredNodes = ref([enabledNode])
const selectedIds = ref(new Set(['b']))
return {
autoEnableNew: ref(true),
isLoading: ref(false),
loadError: ref(false),
searchQuery: ref(''),
sortField: ref<'name' | 'lastModified'>('name'),
sortDirection: ref<'asc' | 'desc'>('asc'),
nodes,
selectedIds,
selectedCount: computed(() => selectedIds.value.size),
selectedEnabled: computed(() =>
nodes.value
.filter((node) => selectedIds.value.has(node.id))
.every((node) => node.enabled)
),
selectAllState: ref<'indeterminate'>('indeterminate'),
filteredNodes,
groups: ref([
{
partner: 'BFL',
nodes: [enabledNode],
allNodes: [enabledNode, hiddenDisabledNode],
enabledCount: 1,
totalCount: 2,
lastModified: '2026-01-02T00:00:00Z',
expanded: true
}
]),
togglePartnerCollapsed: vi.fn(),
groupSelectionState: vi.fn(() => false),
toggleGroupSelection: vi.fn(),
fetch: vi.fn(),
toggleSort: vi.fn(),
setEnabled: vi.fn(),
setSelectedEnabled: vi.fn(),
setAllFilteredEnabled: vi.fn(),
setGroupEnabled: vi.fn(),
setAutoEnableNew: vi.fn(),
toggleSelection: vi.fn(),
toggleSelectAll: vi.fn(),
clearSelection: vi.fn()
}
})
vi.mock('@/platform/workspace/composables/usePartnerNodes', () => ({
usePartnerNodes: () => mock
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderPanel() {
return render(PartnerNodesPanelContent, {
props: { search: '' },
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
}
describe('PartnerNodesPanelContent', () => {
beforeEach(() => {
mock.isLoading.value = false
mock.loadError.value = false
mock.selectedIds.value = new Set(['b'])
mock.groups.value = [
{
partner: 'BFL',
nodes: [mock.nodes.value[0]],
allNodes: [...mock.nodes.value],
enabledCount: 1,
totalCount: 2,
lastModified: '2026-01-02T00:00:00Z',
expanded: true
}
]
vi.clearAllMocks()
})
it('provides keyboard-operable provider expansion and named switches', async () => {
const user = userEvent.setup()
renderPanel()
const collapseButton = screen.getByRole('button', {
name: 'Collapse BFL partner nodes'
})
expect(collapseButton).toHaveAttribute('aria-expanded', 'true')
await user.click(collapseButton)
expect(mock.togglePartnerCollapsed).toHaveBeenCalledWith('BFL')
expect(
screen.getByRole('switch', {
name: 'Enable or disable BFL partner nodes'
})
).toBeInTheDocument()
expect(
screen.getByRole('switch', {
name: 'Enable or disable Zeta Node'
})
).toBeInTheDocument()
expect(
screen.getByRole('switch', {
name: 'Automatically enable newly added partner nodes'
})
).toBeInTheDocument()
expect(
screen.getByRole('switch', {
name: 'Enable or disable selected partner nodes'
})
).not.toBeChecked()
})
it('shows loading state and disables mutation controls before fetch settles', () => {
mock.isLoading.value = true
mock.groups.value = []
renderPanel()
expect(screen.getByText('Loading partner nodes...')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Enable all' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Disable all' })).toBeDisabled()
expect(
screen.getByRole('switch', {
name: 'Automatically enable newly added partner nodes'
})
).toBeDisabled()
expect(
screen.queryByText('No partner nodes match your search.')
).not.toBeInTheDocument()
})
it('shows a distinct load error with a retry action', async () => {
const user = userEvent.setup()
mock.loadError.value = true
mock.groups.value = []
renderPanel()
expect(screen.getByText('Failed to load partner nodes')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Retry' }))
expect(mock.fetch).toHaveBeenCalled()
expect(
screen.queryByText('No partner nodes match your search.')
).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,375 @@
<template>
<div class="@container relative flex min-h-0 flex-1 flex-col gap-4 pb-6">
<div class="flex flex-col gap-3 @2xl:flex-row @2xl:items-center @2xl:gap-6">
<span class="min-w-0 flex-1 text-sm text-muted-foreground">
{{ $t('workspacePanel.partnerNodes.description') }}
</span>
<div class="flex shrink-0 items-center gap-2">
<Button
variant="muted-textonly"
size="lg"
:disabled="controlsDisabled"
@click="setAllFilteredEnabled(true)"
>
{{ $t('workspacePanel.allowlist.enableAll') }}
</Button>
<Button
variant="muted-textonly"
size="lg"
:disabled="controlsDisabled"
@click="setAllFilteredEnabled(false)"
>
{{ $t('workspacePanel.allowlist.disableAll') }}
</Button>
</div>
</div>
<BillingStatusBanner />
<div
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-interface-stroke/60"
>
<Table class="min-h-0 flex-1 scrollbar-gutter-stable px-4">
<TableHeader class="sticky top-0 z-10 bg-base-background">
<TableRow
class="hover:bg-transparent [&>th]:h-14 [&>th]:border-b [&>th]:border-interface-stroke/60"
>
<TableHead class="w-6">
<Checkbox
:model-value="selectAllState"
:aria-label="$t('workspacePanel.partnerNodes.selectAll')"
:disabled="controlsDisabled"
@update:model-value="toggleSelectAll"
/>
</TableHead>
<TableHead :aria-sort="ariaSort('name')">
<button :class="sortHeaderClass" @click="toggleSort('name')">
{{ $t('workspacePanel.partnerNodes.columns.name') }}
<i :class="sortIcon('name')" />
</button>
</TableHead>
<TableHead class="w-40">
{{ $t('workspacePanel.partnerNodes.columns.nodes') }}
</TableHead>
<TableHead class="w-40" :aria-sort="ariaSort('lastModified')">
<button
:class="sortHeaderClass"
@click="toggleSort('lastModified')"
>
{{ $t('workspacePanel.partnerNodes.columns.lastModified') }}
<i :class="sortIcon('lastModified')" />
</button>
</TableHead>
<TableHead class="w-14" />
</TableRow>
</TableHeader>
<TableBody>
<template v-for="group in groups" :key="group.partner">
<TableRow
class="hover:bg-transparent [&:hover>td]:bg-secondary-background/50 [&>td]:border-b [&>td]:border-interface-stroke/20 [&>td]:transition-colors"
>
<TableCell>
<Checkbox
:model-value="groupSelectionState(group)"
:aria-label="group.partner"
:disabled="controlsDisabled"
@update:model-value="toggleGroupSelection(group)"
/>
</TableCell>
<TableCell>
<button
type="button"
:aria-expanded="group.expanded"
:aria-label="
$t(
group.expanded
? 'workspacePanel.partnerNodes.collapseProvider'
: 'workspacePanel.partnerNodes.expandProvider',
{ partner: group.partner }
)
"
:disabled="controlsDisabled"
class="flex w-full items-center gap-2 border-none bg-transparent p-0 text-left disabled:cursor-not-allowed"
@click="togglePartnerCollapsed(group.partner)"
>
<i
:class="
cn(
'icon-[lucide--chevron-right] size-4 shrink-0 text-muted-foreground transition-transform',
group.expanded && 'rotate-90'
)
"
/>
<div
:class="
cn(
'flex items-center gap-2',
group.enabledCount === 0 && 'opacity-30'
)
"
>
<PartnerBadge :partner="group.partner" />
<span class="font-medium text-base-foreground">
{{ group.partner }}
</span>
</div>
</button>
</TableCell>
<TableCell class="text-muted-foreground tabular-nums">
{{
$t('workspacePanel.partnerNodes.groupCount', {
enabled: group.enabledCount,
total: group.totalCount
})
}}
</TableCell>
<TableCell class="text-muted-foreground">
{{ formatLastModified(group.lastModified) }}
</TableCell>
<TableCell class="text-right">
<!-- On when any node in the group is enabled; the count column
carries the partial state. Off disables the whole group. -->
<Switch
:model-value="group.enabledCount > 0"
:aria-label="
$t('workspacePanel.partnerNodes.groupToggle', {
partner: group.partner
})
"
:disabled="controlsDisabled"
@update:model-value="
(v: boolean) => setGroupEnabled(group, v)
"
/>
</TableCell>
</TableRow>
<template v-if="group.expanded">
<TableRow
v-for="node in group.nodes"
:key="node.id"
:data-state="selectedIds.has(node.id) ? 'selected' : undefined"
class="group hover:bg-transparent data-[state=selected]:bg-transparent [&:hover>td]:bg-secondary-background/50 [&>td]:border-b [&>td]:border-interface-stroke/20 [&>td]:transition-colors [&[data-state=selected]>td]:bg-secondary-background/50"
>
<TableCell>
<Checkbox
:model-value="selectedIds.has(node.id)"
:aria-label="node.name"
:disabled="controlsDisabled"
:class="
cn(
!hasSelection &&
'opacity-0 transition-opacity group-hover:opacity-100 focus:opacity-100'
)
"
@update:model-value="toggleSelection(node.id)"
/>
</TableCell>
<TableCell class="text-muted-foreground">
<div :class="cn('pl-7', !node.enabled && 'opacity-30')">
{{ node.name }}
</div>
</TableCell>
<TableCell />
<TableCell class="text-muted-foreground">
{{ formatLastModified(node.last_modified) }}
</TableCell>
<TableCell class="text-right">
<Switch
:model-value="node.enabled"
:aria-label="
$t('workspacePanel.partnerNodes.nodeToggle', {
name: node.name
})
"
:disabled="controlsDisabled"
@update:model-value="(v: boolean) => setEnabled(node, v)"
/>
</TableCell>
</TableRow>
</template>
</template>
<TableRow v-if="isLoading" class="hover:bg-transparent">
<TableCell
:colspan="5"
class="py-6 text-center text-sm text-muted-foreground"
>
<span role="status">
{{ $t('workspacePanel.partnerNodes.loading') }}
</span>
</TableCell>
</TableRow>
<TableRow v-else-if="loadError" class="hover:bg-transparent">
<TableCell
:colspan="5"
class="py-6 text-center text-sm text-muted-foreground"
>
<div class="flex items-center justify-center gap-2">
<span role="alert">
{{ $t('workspacePanel.partnerNodes.loadError') }}
</span>
<Button variant="muted-textonly" @click="fetch">
{{ $t('workspacePanel.partnerNodes.retry') }}
</Button>
</div>
</TableCell>
</TableRow>
<TableRow
v-else-if="groups.length === 0"
class="hover:bg-transparent"
>
<TableCell
:colspan="5"
class="py-6 text-center text-sm text-muted-foreground"
>
{{ $t('workspacePanel.partnerNodes.empty') }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- Auto-enable default: toggle-first sentence, bottom-left. -->
<div class="flex h-8 items-center gap-3 text-sm text-muted-foreground">
<Switch
:model-value="autoEnableNew"
:aria-label="$t('workspacePanel.partnerNodes.autoEnableLabel')"
:disabled="controlsDisabled"
@update:model-value="setAutoEnableNew"
/>
<!-- The sentence lights up with the toggle: foreground when the default
is on, muted when off. -->
<span
:class="
cn(
'transition-colors',
autoEnableNew ? 'text-base-foreground' : 'text-muted-foreground'
)
"
>
{{ $t('workspacePanel.partnerNodes.autoEnableVerb') }}
{{ $t('workspacePanel.partnerNodes.autoEnableSubject') }}
</span>
</div>
<!-- Bulk selection toolbar: overlaid so toggling it doesn't reflow the panel -->
<div class="absolute inset-x-0 bottom-0">
<Transition
enter-active-class="transition-opacity duration-150"
leave-active-class="transition-opacity duration-150"
enter-from-class="opacity-0"
leave-to-class="opacity-0"
>
<SelectionBar
v-if="selectedCount > 0"
:label="
$t('workspacePanel.partnerNodes.selectedCount', selectedCount)
"
:deselect-label="$t('workspacePanel.partnerNodes.clearSelection')"
@deselect="clearSelection"
>
<Switch
:model-value="selectedEnabled"
:aria-label="$t('workspacePanel.partnerNodes.bulkToggle')"
:disabled="controlsDisabled"
@update:model-value="applyBulk"
/>
</SelectionBar>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import SelectionBar from '@/components/common/SelectionBar.vue'
import Button from '@/components/ui/button/Button.vue'
import Checkbox from '@/components/ui/checkbox/Checkbox.vue'
import Switch from '@/components/ui/switch/Switch.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 BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
import PartnerBadge from '@/platform/workspace/components/dialogs/settings/PartnerBadge.vue'
import { usePartnerNodes } from '@/platform/workspace/composables/usePartnerNodes'
import { cn } from '@comfyorg/tailwind-utils'
const { search } = defineProps<{ search: string }>()
const { t } = useI18n()
const {
autoEnableNew,
isLoading,
loadError,
searchQuery,
sortField,
sortDirection,
selectedIds,
selectedCount,
selectedEnabled,
selectAllState,
groups,
togglePartnerCollapsed,
groupSelectionState,
toggleGroupSelection,
fetch,
toggleSort,
setEnabled,
setSelectedEnabled,
setAllFilteredEnabled,
setGroupEnabled,
setAutoEnableNew,
toggleSelection,
toggleSelectAll,
clearSelection
} = usePartnerNodes()
// Search lives in the Allowlist tab row (shared with the Models tab).
watch(
() => search,
(value) => {
searchQuery.value = value
}
)
const hasSelection = computed(() => selectedCount.value > 0)
const controlsDisabled = computed(() => isLoading.value || loadError.value)
const sortHeaderClass =
'flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left font-[inherit] text-sm text-muted-foreground'
function sortIcon(field: 'name' | 'lastModified') {
if (sortField.value !== field) return 'icon-[lucide--chevrons-up-down] size-3'
return sortDirection.value === 'asc'
? 'icon-[lucide--chevron-up] size-3'
: 'icon-[lucide--chevron-down] size-3'
}
function ariaSort(
field: 'name' | 'lastModified'
): 'ascending' | 'descending' | 'none' {
if (sortField.value !== field) return 'none'
return sortDirection.value === 'asc' ? 'ascending' : 'descending'
}
function applyBulk(value: boolean) {
void setSelectedEnabled(value)
}
function formatLastModified(iso: string | null): string {
if (!iso) return t('workspacePanel.partnerNodes.neverModified')
return new Date(iso).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
onMounted(fetch)
</script>

View File

@@ -0,0 +1,88 @@
<template>
<TableRow
:data-testid="`invite-row-${invite.id}`"
class="group hover:bg-transparent"
>
<TableCell>
<div class="flex items-center gap-3">
<span
class="flex size-8 shrink-0 items-center justify-center rounded-full"
:style="{ backgroundColor: userBadgeColor(invite.email) }"
>
<span class="text-sm font-bold text-base-foreground">
{{ inviteInitial }}
</span>
</span>
<span class="min-w-0 flex-1 truncate text-sm text-base-foreground">
{{ invite.email }}
</span>
</div>
</TableCell>
<TableCell class="text-sm text-muted-foreground">
{{ formatDate(invite.inviteDate) }}
</TableCell>
<TableCell class="text-sm text-muted-foreground">
{{ formatDate(invite.expiryDate) }}
</TableCell>
<TableCell v-if="canManage" class="text-right" @click.stop>
<DropdownMenu
:entries="menuItems"
:modal="false"
content-class="min-w-44"
>
<template #button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
>
<i class="pi pi-ellipsis-h" />
</Button>
</template>
</DropdownMenu>
</TableCell>
</TableRow>
</template>
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import Button from '@/components/ui/button/Button.vue'
import TableCell from '@/components/ui/table/TableCell.vue'
import TableRow from '@/components/ui/table/TableRow.vue'
import type { PendingInvite } from '@/platform/workspace/stores/teamWorkspaceStore'
import { userBadgeColor } from '@/platform/workspace/utils/badgeColor'
const { invite, canManage } = defineProps<{
invite: PendingInvite
canManage: boolean
}>()
const emit = defineEmits<{
resend: [invite: PendingInvite]
revoke: [invite: PendingInvite]
}>()
const { t, d } = useI18n()
const inviteInitial = computed(() => invite.email.charAt(0).toUpperCase())
const menuItems = computed<MenuItem[]>(() => [
{
label: t('workspacePanel.members.actions.resendInvite'),
command: () => emit('resend', invite)
},
{
label: t('workspacePanel.members.actions.cancelInvite'),
command: () => emit('revoke', invite)
}
])
function formatDate(date: Date): string {
return d(date, { dateStyle: 'medium' })
}
</script>

View File

@@ -1,85 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Slots } from 'vue'
import { h } from 'vue'
import { createI18n } from 'vue-i18n'
import PendingInvitesList from './PendingInvitesList.vue'
import type { PendingInvite } from '../../../stores/teamWorkspaceStore'
const mockMenuClose = vi.hoisted(() => vi.fn())
vi.mock('@/components/button/MoreButton.vue', () => ({
default: (_: unknown, { slots }: { slots: Slots }) =>
h('div', slots.default?.({ close: mockMenuClose }))
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
function createInvite(overrides: Partial<PendingInvite> = {}): PendingInvite {
return {
id: 'invite-1',
email: 'invitee@example.com',
inviteDate: new Date('2025-03-01'),
expiryDate: new Date('2025-04-01'),
...overrides
}
}
function renderComponent(invites: PendingInvite[]) {
return render(PendingInvitesList, {
props: {
invites,
gridCols: 'grid-cols-[50%_20%_20%_10%]'
},
global: { plugins: [i18n] }
})
}
describe('PendingInvitesList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows the empty state without action buttons when there are no invites', () => {
renderComponent([])
expect(screen.getByText('workspacePanel.members.noInvites')).toBeTruthy()
expect(screen.queryAllByRole('button')).toHaveLength(0)
})
it('emits resend with the invite and closes the menu', async () => {
const invite = createInvite({ id: 'inv-7' })
const { emitted } = renderComponent([invite])
await userEvent.click(
screen.getByRole('button', {
name: 'workspacePanel.members.actions.resendInvite'
})
)
expect(emitted('resend')).toEqual([[invite]])
expect(mockMenuClose).toHaveBeenCalled()
})
it('emits revoke with the invite from the cancel item', async () => {
const invite = createInvite({ id: 'inv-8' })
const { emitted } = renderComponent([invite])
await userEvent.click(
screen.getByRole('button', {
name: 'workspacePanel.members.actions.cancelInvite'
})
)
expect(emitted('revoke')).toEqual([[invite]])
})
})

View File

@@ -1,112 +0,0 @@
<template>
<div>
<div
v-for="(invite, index) in invites"
:key="invite.id"
:class="
cn(
'grid w-full items-center rounded-lg p-2',
gridCols,
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">
{{ 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>
<span class="text-sm text-muted-foreground">
{{ formatDate(invite.inviteDate) }}
</span>
<span class="text-sm text-muted-foreground">
{{ formatDate(invite.expiryDate) }}
</span>
<div class="flex items-center justify-end">
<MoreButton v-slot="{ close }" :aria-label="$t('g.moreOptions')">
<Button
variant="textonly"
size="unset"
:class="menuItemClass"
@click="
() => {
close()
$emit('resend', invite)
}
"
>
<i class="icon-[lucide--mail-plus] size-4" />
<span>{{ $t('workspacePanel.members.actions.resendInvite') }}</span>
</Button>
<Button
variant="textonly"
size="unset"
:class="menuItemClass"
@click="
() => {
close()
$emit('revoke', invite)
}
"
>
<i class="icon-[lucide--mail-x] size-4" />
<span>{{ $t('workspacePanel.members.actions.cancelInvite') }}</span>
</Button>
</MoreButton>
</div>
</div>
<div
v-if="invites.length === 0"
class="flex w-full items-center justify-center py-8 text-sm text-muted-foreground"
>
{{ $t('workspacePanel.members.noInvites') }}
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import MoreButton from '@/components/button/MoreButton.vue'
import Button from '@/components/ui/button/Button.vue'
import type { PendingInvite } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@comfyorg/tailwind-utils'
const menuItemClass = 'w-full justify-start rounded-sm px-3 py-2'
defineProps<{
invites: PendingInvite[]
gridCols: string
}>()
defineEmits<{
resend: [invite: PendingInvite]
revoke: [invite: PendingInvite]
}>()
const { d } = useI18n()
function getInviteDisplayName(email: string): string {
return email.split('@')[0]
}
function getInviteInitial(email: string): string {
return email.charAt(0).toUpperCase()
}
function formatDate(date: Date): string {
return d(date, { dateStyle: 'medium' })
}
</script>

View File

@@ -0,0 +1,72 @@
import { render, waitFor } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import WorkspaceMembersPanelContent from './WorkspaceMembersPanelContent.vue'
const mockEnsureMembersLoaded = vi.fn()
const mockFetchPendingInvites = vi.fn()
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({ workspaceRole: ref('owner') })
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
ensureMembersLoaded: mockEnsureMembersLoaded,
fetchPendingInvites: mockFetchPendingInvites
})
}))
describe('WorkspaceMembersPanelContent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockEnsureMembersLoaded.mockResolvedValue(undefined)
mockFetchPendingInvites.mockResolvedValue(undefined)
})
it('loads members and pending invites on mount', () => {
render(WorkspaceMembersPanelContent, {
global: { stubs: { MembersPanelContent: true } }
})
expect(mockEnsureMembersLoaded).toHaveBeenCalledOnce()
expect(mockFetchPendingInvites).toHaveBeenCalledOnce()
})
it('handles pending invite load failures', async () => {
const error = new Error('network failure')
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockFetchPendingInvites.mockRejectedValue(error)
render(WorkspaceMembersPanelContent, {
global: { stubs: { MembersPanelContent: true } }
})
await waitFor(() =>
expect(consoleError).toHaveBeenCalledWith(
'Failed to load pending workspace invites',
error
)
)
consoleError.mockRestore()
})
it('handles member load failures', async () => {
const error = new Error('network failure')
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockEnsureMembersLoaded.mockRejectedValue(error)
render(WorkspaceMembersPanelContent, {
global: { stubs: { MembersPanelContent: true } }
})
await waitFor(() =>
expect(consoleError).toHaveBeenCalledWith(
'Failed to load workspace members',
error
)
)
consoleError.mockRestore()
})
})

View File

@@ -0,0 +1,25 @@
<template>
<div class="flex size-full flex-col">
<MembersPanelContent :key="workspaceRole" />
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
const { workspaceRole } = useWorkspaceUI()
const { ensureMembersLoaded, fetchPendingInvites } = useTeamWorkspaceStore()
onMounted(() => {
void ensureMembersLoaded().catch((error: unknown) => {
console.error('Failed to load workspace members', error)
})
void fetchPendingInvites().catch((error: unknown) => {
console.error('Failed to load pending workspace invites', error)
})
})
</script>

View File

@@ -5,6 +5,7 @@ import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import { useWorkspaceRename } from '@/platform/workspace/composables/useWorkspaceRename'
import WorkspaceMenuButton from './WorkspaceMenuButton.vue'
@@ -75,6 +76,25 @@ describe('WorkspaceMenuButton', () => {
mockUiConfig.value = ownerConfig
mockIsCurrentUserOriginalOwner.value = false
mockIsWorkspaceSubscribed.value = false
useWorkspaceRename().stopRenaming()
})
it('offers Rename to an editor and starts inline renaming', async () => {
const user = userEvent.setup()
const { isRenaming } = useWorkspaceRename()
renderComponent()
await user.click(screen.getByRole('button', { name: 'Rename Workspace' }))
expect(isRenaming.value).toBe(true)
})
it('hides Rename from a member', () => {
mockUiConfig.value = memberConfig
renderComponent()
expect(
screen.queryByRole('button', { name: 'Rename Workspace' })
).not.toBeInTheDocument()
})
it('lets a member leave and offers no destructive workspace actions', () => {

View File

@@ -1,10 +1,16 @@
<template>
<DropdownMenu :entries="menuItems">
<DropdownMenu
v-if="menuItems.length > 0"
:entries="menuItems"
:modal="false"
@close-auto-focus="onMenuCloseAutoFocus"
>
<template #button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="muted-textonly"
variant="secondary"
size="icon-lg"
class="rounded-lg"
:aria-label="$t('g.moreOptions')"
>
<i class="pi pi-ellipsis-h" />
@@ -21,20 +27,35 @@ import { useI18n } from 'vue-i18n'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import Button from '@/components/ui/button/Button.vue'
import { useWorkspaceRename } from '@/platform/workspace/composables/useWorkspaceRename'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
const { t } = useI18n()
const {
showLeaveWorkspaceDialog,
showDeleteWorkspaceDialog,
showEditWorkspaceDialog
} = useDialogService()
const { showLeaveWorkspaceDialog, showDeleteWorkspaceDialog } =
useDialogService()
const { isWorkspaceSubscribed, isCurrentUserOriginalOwner } = storeToRefs(
useTeamWorkspaceStore()
)
const { uiConfig } = useWorkspaceUI()
const { startRenaming } = useWorkspaceRename()
// Reka returns focus to the trigger when the menu closes, which would blur (and
// so tear down) the rename input we're about to focus. Suppress that focus
// restoration for the one close that kicks off a rename.
let renameStarting = false
function beginRename() {
renameStarting = true
startRenaming()
}
function onMenuCloseAutoFocus(event: Event) {
if (!renameStarting) return
renameStarting = false
event.preventDefault()
}
// Disable delete when the workspace has an active subscription (prevents
// accidental deletion); uses the workspace's own status, not the global one.
@@ -51,22 +72,21 @@ const deleteTooltip = computed(() => {
})
const menuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = []
if (uiConfig.value.showEditWorkspaceMenuItem) {
items.push({
label: t('workspacePanel.menu.editWorkspace'),
icon: 'pi pi-pencil',
command: () => showEditWorkspaceDialog()
})
}
const renameItems: MenuItem[] = uiConfig.value.showEditWorkspaceMenuItem
? [
{
label: t('workspacePanel.menu.renameWorkspace'),
command: beginRename
}
]
: []
const destructiveItems: MenuItem[] = []
const action = uiConfig.value.workspaceMenuAction
if (action === 'delete') {
items.push({
destructiveItems.push({
label: t('workspacePanel.menu.deleteWorkspace'),
icon: 'pi pi-trash',
class: isDeleteDisabled.value ? 'text-danger/50' : 'text-danger',
class: isDeleteDisabled.value ? undefined : 'text-danger',
disabled: isDeleteDisabled.value,
tooltip: deleteTooltip.value,
command: isDeleteDisabled.value
@@ -77,23 +97,23 @@ const menuItems = computed<MenuItem[]>(() => {
// Members and non-creator owners can leave; the creator sees it disabled.
if (action === 'leave' || action === 'delete') {
items.push(
destructiveItems.push(
isCurrentUserOriginalOwner.value
? {
label: t('workspacePanel.menu.leaveWorkspace'),
icon: 'pi pi-sign-out',
class: 'opacity-50',
disabled: true,
tooltip: t('workspacePanel.menu.creatorCannotLeave')
}
: {
label: t('workspacePanel.menu.leaveWorkspace'),
icon: 'pi pi-sign-out',
command: () => showLeaveWorkspaceDialog()
}
)
}
return items
const divider: MenuItem[] =
renameItems.length && destructiveItems.length ? [{ separator: true }] : []
return [...renameItems, ...divider, ...destructiveItems]
})
</script>

View File

@@ -0,0 +1,82 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import { useWorkspaceRename } from '@/platform/workspace/composables/useWorkspaceRename'
import WorkspaceSettingsHeader from './WorkspaceSettingsHeader.vue'
const mockUpdateWorkspaceName = vi.fn()
vi.mock('pinia', async (importOriginal) => {
const actual = await importOriginal()
return { ...(actual as object), storeToRefs: (store: object) => store }
})
vi.mock('primevue/usetoast', () => ({
useToast: () => ({ add: vi.fn() })
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
uiConfig: ref({ showEditWorkspaceMenuItem: true })
})
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
workspaceName: ref('Acme Team'),
updateWorkspaceName: mockUpdateWorkspaceName
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
describe('WorkspaceSettingsHeader', () => {
afterEach(() => {
useWorkspaceRename().stopRenaming()
vi.clearAllMocks()
})
it('labels the workspace rename input', () => {
useWorkspaceRename().startRenaming()
render(WorkspaceSettingsHeader, {
global: {
plugins: [i18n],
directives: { tooltip: {} },
stubs: { WorkspaceProfilePic: true }
}
})
expect(screen.getByRole('textbox', { name: 'Workspace name' })).toHaveValue(
'Acme Team'
)
})
it('submits a renamed workspace', async () => {
const user = userEvent.setup()
useWorkspaceRename().startRenaming()
render(WorkspaceSettingsHeader, {
global: {
plugins: [i18n],
directives: { tooltip: {} },
stubs: { WorkspaceProfilePic: true }
}
})
const input = screen.getByRole('textbox', { name: 'Workspace name' })
await user.clear(input)
await user.type(input, 'Renamed Team{Enter}')
expect(mockUpdateWorkspaceName).toHaveBeenCalledWith('Renamed Team')
})
})

View File

@@ -0,0 +1,148 @@
<template>
<div class="flex min-w-0 items-center gap-4">
<div class="group relative size-12 shrink-0">
<WorkspaceProfilePic
class="size-12 rounded-lg text-2xl"
:workspace-name="workspaceName"
:image-url="imageUrl ?? undefined"
/>
<button
v-if="canEdit"
type="button"
class="absolute inset-0 flex cursor-pointer items-center justify-center rounded-lg border-none bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
:aria-label="$t('workspacePanel.editWorkspaceImage')"
@click="pickImage"
>
<i class="icon-[lucide--pencil] size-4 text-white" />
</button>
<input
ref="fileInputRef"
type="file"
accept="image/*"
class="hidden"
@change="onFileChange"
/>
</div>
<input
v-if="isRenaming"
ref="inputRef"
v-model="draftName"
:maxlength="MAX_NAME_LENGTH"
:aria-label="$t('workspacePanel.editWorkspaceDialog.nameLabel')"
class="min-w-0 flex-1 appearance-none border-none bg-transparent p-0 font-[inherit] text-2xl font-semibold text-base-foreground outline-none"
@keydown.enter="commit"
@keydown.esc="cancel"
@blur="commit"
/>
<h1
v-else
v-tooltip="
canEdit
? { value: $t('workspacePanel.doubleClickToRename'), showDelay: 300 }
: undefined
"
class="truncate text-2xl font-semibold text-base-foreground"
@dblclick="beginRename"
>
{{ workspaceName }}
</h1>
<span
v-if="isRenaming && remaining <= 10"
class="shrink-0 text-sm text-muted-foreground tabular-nums"
>
{{ $t('workspacePanel.charactersLeft', remaining) }}
</span>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { storeToRefs } from 'pinia'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
import { useWorkspaceRename } from '@/platform/workspace/composables/useWorkspaceRename'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { WORKSPACE_NAME_MAX_LENGTH } from '@/platform/workspace/workspaceConstants'
const { t } = useI18n()
const toast = useToast()
const store = useTeamWorkspaceStore()
const { workspaceName } = storeToRefs(store)
const { uiConfig } = useWorkspaceUI()
const MAX_NAME_LENGTH = WORKSPACE_NAME_MAX_LENGTH
// Renaming is gated to Owner + Admins (and the sole owner of a personal
// workspace); Members never see the affordance.
const canEdit = computed(() => uiConfig.value.showEditWorkspaceMenuItem)
const { isRenaming, startRenaming, stopRenaming } = useWorkspaceRename()
const draftName = ref('')
const inputRef = ref<HTMLInputElement | null>(null)
// A single entry point (double-click here or the "Rename" menu item) flips
// `isRenaming`; seed the draft and focus the field once the input mounts.
watch(
isRenaming,
(renaming) => {
if (!renaming) return
draftName.value = workspaceName.value
void nextTick(() => {
inputRef.value?.focus()
inputRef.value?.select()
})
},
{ immediate: true }
)
// Surface the limit only as the user approaches it, to keep the header quiet.
const remaining = computed(() => MAX_NAME_LENGTH - draftName.value.length)
// Client-side only preview (prototype): the picked image is held locally, not
// uploaded or persisted. Resets on reload.
const imageUrl = ref<string | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
function pickImage() {
fileInputRef.value?.click()
}
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
const reader = new FileReader()
reader.onload = () => {
imageUrl.value = reader.result as string
}
reader.readAsDataURL(file)
}
function beginRename() {
if (!canEdit.value) return
startRenaming()
}
async function commit() {
if (!isRenaming.value) return
stopRenaming()
const name = draftName.value.trim()
if (!name || name === workspaceName.value) return
try {
await store.updateWorkspaceName(name)
} catch {
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToUpdateWorkspace')
})
}
}
function cancel() {
stopRenaming()
}
</script>

View File

@@ -0,0 +1,39 @@
<template>
<ul class="m-0 list-none space-y-1 p-0 px-3">
<li
v-for="offender in offenders"
:key="offender.nodeId"
class="flex min-w-0 items-center gap-2"
>
<Button
variant="textonly"
class="max-w-full min-w-0 flex-1 justify-start truncate text-left text-xs/relaxed font-normal text-muted-foreground hover:text-base-foreground"
@click="emit('locateNode', offender.nodeId)"
>
{{ offender.displayName }}
</Button>
<Button
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:aria-label="
$t('rightSidePanel.locateNodeFor', { item: offender.displayName })
"
@click.stop="emit('locateNode', offender.nodeId)"
>
<i class="icon-[lucide--locate] size-4" />
</Button>
</li>
</ul>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import type { DisabledGraphNode } from '@/platform/workspace/stores/disabledPartnerNodesStore'
const { offenders } = defineProps<{ offenders: DisabledGraphNode[] }>()
const emit = defineEmits<{
locateNode: [nodeId: DisabledGraphNode['nodeId']]
}>()
</script>

View File

@@ -115,6 +115,23 @@ describe('sortMembers', () => {
expect(members).toEqual(original)
})
it('sorts the email column by email instead of display name', () => {
const first = createMember({
id: 'first',
name: 'Zulu',
email: 'alice@example.com'
})
const second = createMember({
id: 'second',
name: 'Alpha',
email: 'bob@example.com'
})
const result = sortMembers([second, first], null, 'asc', null, 'email')
expect(result.map((member) => member.id)).toEqual(['first', 'second'])
})
it('pins the original owner first regardless of sort direction', () => {
const creator = createMember({
id: 'creator',
@@ -254,6 +271,7 @@ const mockShowChangeMemberRoleDialog = vi.fn()
const mockShowSubscriptionDialog = vi.fn()
const mockShowInviteMemberDialog = vi.fn()
const mockShowInviteMemberUpsellDialog = vi.fn()
const mockShowMemberLimitDialog = vi.fn()
const {
mockMembers,
@@ -366,6 +384,9 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: mockIsActiveSubscription,
subscription: mockSubscription,
balance: { value: null },
renewalDate: { value: null },
fetchBalance: vi.fn(),
getMaxSeats: (tierKey: string) => {
const seats: Record<string, number> = {
free: 1,
@@ -391,7 +412,8 @@ vi.mock('@/services/dialogService', () => ({
showRevokeInviteDialog: mockShowRevokeInviteDialog,
showChangeMemberRoleDialog: mockShowChangeMemberRoleDialog,
showInviteMemberDialog: mockShowInviteMemberDialog,
showInviteMemberUpsellDialog: mockShowInviteMemberUpsellDialog
showInviteMemberUpsellDialog: mockShowInviteMemberUpsellDialog,
showMemberLimitDialog: mockShowMemberLimitDialog
})
}))
@@ -593,13 +615,13 @@ describe('useMembersPanel', () => {
const roleItems = items[0].items ?? []
expect(roleItems.map((i) => i.label)).toEqual([
'workspaceSwitcher.roleOwner',
'workspaceSwitcher.roleAdmin',
'workspaceSwitcher.roleMember'
])
expect(roleItems.map((i) => i.checked)).toEqual([false, true])
})
it('checks Owner for owner rows', async () => {
it('checks Admin for owner-role rows', async () => {
const panel = await setup()
const items = panel.memberMenuItems(createMember({ role: 'owner' }))
const roleItems = items[0].items ?? []
@@ -706,14 +728,15 @@ describe('useMembersPanel', () => {
expect(mockShowInviteMemberDialog).not.toHaveBeenCalled()
})
it('disables the invite button at the member cap (30)', async () => {
it('opens the member-limit dialog at the member cap (30)', async () => {
mockTotalMemberSlots.value = 30
const panel = await setup()
expect(panel.isInviteDisabled.value).toBe(true)
expect(panel.isInviteDisabled.value).toBe(false)
expect(panel.inviteTooltip.value).toBe(
'workspacePanel.inviteLimitReached'
)
panel.handleInviteMember()
expect(mockShowMemberLimitDialog).toHaveBeenCalled()
expect(mockShowInviteMemberDialog).not.toHaveBeenCalled()
})
@@ -724,10 +747,12 @@ describe('useMembersPanel', () => {
expect(panel.inviteTooltip.value).toBeNull()
})
it('disables the invite button at the flat backend member cap', async () => {
it('opens the member-limit dialog at the flat backend member cap', async () => {
mockIsInviteLimitReached.value = true
const panel = await setup()
expect(panel.isInviteDisabled.value).toBe(true)
expect(panel.isInviteDisabled.value).toBe(false)
panel.handleInviteMember()
expect(mockShowMemberLimitDialog).toHaveBeenCalled()
})
it('disables the invite button when not on a team plan', async () => {

View File

@@ -5,6 +5,7 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { WorkspaceRole } from '@/platform/workspace/api/workspaceApi'
import { useTeamPlan } from '@/platform/workspace/composables/useTeamPlan'
@@ -20,15 +21,37 @@ import {
import { useDialogService } from '@/services/dialogService'
type ActiveView = 'active' | 'pending'
type SortField = 'inviteDate' | 'expiryDate' | 'role'
type SortField =
| 'email'
| 'role'
| 'lastActivity'
| 'credits'
| 'inviteDate'
| 'expiryDate'
type SortDirection = 'asc' | 'desc'
export function sortMembers(
members: WorkspaceMember[],
currentUserEmail: string | null,
sortDirection: SortDirection,
originalOwnerId: string | null = null
originalOwnerId: string | null = null,
sortField: SortField = 'role'
): WorkspaceMember[] {
const dir = sortDirection === 'asc' ? 1 : -1
if (sortField === 'email') {
return [...members].sort((a, b) => dir * a.email.localeCompare(b.email))
}
if (sortField === 'lastActivity') {
const at = (m: WorkspaceMember) => m.lastActivity?.getTime() ?? 0
return [...members].sort((a, b) => dir * (at(a) - at(b)))
}
if (sortField === 'credits') {
const used = (m: WorkspaceMember) => m.creditsUsedThisMonth ?? 0
return [...members].sort((a, b) => dir * (used(a) - used(b)))
}
// Default (role) ordering pins the creator, then groups by role, then recency.
return [...members].sort((a, b) => {
const aIsOriginalOwner = a.id === originalOwnerId
const bIsOriginalOwner = b.id === originalOwnerId
@@ -97,7 +120,8 @@ export function useMembersPanel() {
showRevokeInviteDialog,
showChangeMemberRoleDialog,
showInviteMemberDialog,
showInviteMemberUpsellDialog
showInviteMemberUpsellDialog,
showMemberLimitDialog
} = useDialogService()
const workspaceStore = useTeamWorkspaceStore()
const {
@@ -112,11 +136,14 @@ export function useMembersPanel() {
const { permissions, uiConfig } = useWorkspaceUI()
const { isOnTeamPlan, isCancelled, hasLapsedTeamPlan } = useTeamPlan()
const subscriptionDialog = useSubscriptionDialog()
const { fetchBalance } = useBillingContext()
// The team plan caps members at a flat MAX_WORKSPACE_MEMBERS, independent of
// the subscription tier.
const maxSeats = computed(() => MAX_WORKSPACE_MEMBERS)
const memberCount = computed(() => members.value.length)
const hasMultipleMembers = computed(() => members.value.length > 1)
const showSearch = computed(
@@ -138,16 +165,16 @@ export function useMembersPanel() {
() => isInviteLimitReached.value || totalMemberSlots.value >= maxSeats.value
)
// Invite is allowed only on an active (non-cancelled) team plan that is under
// the member cap.
// Invite stays enabled at the seat cap so the button can surface the
// "at the member limit" dialog; only an inactive/cancelled plan disables it.
const isInviteDisabled = computed(
() => !isOnTeamPlan.value || isCancelled.value || isMemberLimitReached.value
() => !isOnTeamPlan.value || isCancelled.value
)
const inviteTooltip = computed(() => {
if (!isOnTeamPlan.value) return null
if (!isMemberLimitReached.value) return null
return t('workspacePanel.inviteLimitReached', { count: maxSeats.value })
return t('workspacePanel.inviteLimitReached')
})
function handleInviteMember() {
@@ -155,7 +182,11 @@ export function useMembersPanel() {
void showInviteMemberUpsellDialog()
return
}
if (isCancelled.value || isMemberLimitReached.value) return
if (isCancelled.value) return
if (isMemberLimitReached.value) {
void showMemberLimitDialog()
return
}
void showInviteMemberDialog()
}
@@ -190,7 +221,7 @@ export function useMembersPanel() {
{
label: t('workspacePanel.members.actions.changeRole'),
items: [
roleMenuItem(member, 'owner', t('workspaceSwitcher.roleOwner')),
roleMenuItem(member, 'owner', t('workspaceSwitcher.roleAdmin')),
roleMenuItem(member, 'member', t('workspaceSwitcher.roleMember'))
]
},
@@ -215,13 +246,14 @@ export function useMembersPanel() {
searched,
userEmail.value ?? null,
sortDirection.value,
originalOwnerId.value
originalOwnerId.value,
sortField.value
)
})
// Built once per member list rather than per row on every render, so an
// unrelated re-render (e.g. typing in the search box) doesn't rebuild every
// row's menu and churn MemberListItem's props.
// row's menu and churn MemberTableRow's props.
const memberMenus = computed(
() => new Map(filteredMembers.value.map((m) => [m.id, memberMenuItems(m)]))
)
@@ -286,9 +318,11 @@ export function useMembersPanel() {
sortField,
sortDirection,
maxSeats,
memberCount,
isOnTeamPlan,
hasLapsedTeamPlan,
hasMultipleMembers,
fetchBalance,
showSearch,
showViewTabs,
showInviteButton,

View File

@@ -0,0 +1,471 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PartnerNode } from '@/platform/workspace/api/partnerNodesApi'
import { partnerNodesApi } from '@/platform/workspace/api/partnerNodesApi'
import { usePartnerNodes } from '@/platform/workspace/composables/usePartnerNodes'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key })
}))
const mockToastAdd = vi.fn()
vi.mock('primevue/usetoast', () => ({
useToast: () => ({ add: mockToastAdd })
}))
vi.mock('@/platform/workspace/api/partnerNodesApi', () => ({
partnerNodesApi: {
list: vi.fn(),
setEnabled: vi.fn(),
setEnabledBulk: vi.fn(),
setAutoEnableNew: vi.fn()
}
}))
const mockApplyGovernanceChange = vi.fn()
vi.mock('@/platform/workspace/stores/disabledPartnerNodesStore', () => ({
useDisabledPartnerNodesStore: () => ({
applyGovernanceChange: mockApplyGovernanceChange
})
}))
function node(overrides: Partial<PartnerNode> = {}): PartnerNode {
return {
id: 'pn-1',
name: 'Anthropic Claude',
partner: 'Anthropic',
last_modified: null,
enabled: true,
...overrides
}
}
const sampleNodes: PartnerNode[] = [
node({ id: 'a', name: 'Zeta Node', partner: 'BFL', enabled: true }),
node({ id: 'b', name: 'Alpha Node', partner: 'Anthropic', enabled: false }),
node({ id: 'c', name: 'Beta Node', partner: 'BFL', enabled: true })
]
type Deferred<T> = {
promise: Promise<T>
resolve: (value: T) => void
reject: (reason?: unknown) => void
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolveFn, rejectFn) => {
resolve = resolveFn
reject = rejectFn
})
return { promise, resolve, reject }
}
async function setupLoaded() {
vi.mocked(partnerNodesApi.list).mockResolvedValue({
partner_nodes: sampleNodes.map((n) => ({ ...n })),
auto_enable_new: true
})
const pn = usePartnerNodes()
await pn.fetch()
return pn
}
describe('usePartnerNodes', () => {
beforeEach(() => {
vi.clearAllMocks()
mockApplyGovernanceChange.mockResolvedValue(undefined)
})
it('loads nodes and the auto-enable default', async () => {
const pn = await setupLoaded()
expect(pn.nodes.value).toHaveLength(3)
expect(pn.autoEnableNew.value).toBe(true)
})
it('sorts by name ascending by default and toggles direction', async () => {
const pn = await setupLoaded()
expect(pn.filteredNodes.value.map((n) => n.name)).toEqual([
'Alpha Node',
'Beta Node',
'Zeta Node'
])
pn.toggleSort('name')
expect(pn.filteredNodes.value.map((n) => n.name)).toEqual([
'Zeta Node',
'Beta Node',
'Alpha Node'
])
})
it('filters by search across name and partner', async () => {
const pn = await setupLoaded()
pn.searchQuery.value = 'anthropic'
expect(pn.filteredNodes.value.map((n) => n.id)).toEqual(['b'])
})
it('keeps provider counts and controls scoped to the full provider', async () => {
const pn = await setupLoaded()
pn.searchQuery.value = 'Zeta'
const bfl = pn.groups.value.find((group) => group.partner === 'BFL')!
expect(bfl.nodes.map((n) => n.id)).toEqual(['a'])
expect(bfl.enabledCount).toBe(2)
expect(bfl.totalCount).toBe(2)
await pn.setGroupEnabled(bfl, false)
expect(partnerNodesApi.setEnabledBulk).toHaveBeenCalledWith(
['a', 'c'],
false
)
})
it('sorts provider rows by the active column', async () => {
const pn = await setupLoaded()
pn.nodes.value = pn.nodes.value.map((n) => ({
...n,
last_modified:
n.partner === 'BFL' ? '2026-01-02T00:00:00Z' : '2026-01-01T00:00:00Z'
}))
pn.toggleSort('lastModified')
pn.toggleSort('lastModified')
expect(pn.groups.value.map((group) => group.partner)).toEqual([
'BFL',
'Anthropic'
])
})
it('optimistically toggles a node and calls the api', async () => {
const pn = await setupLoaded()
const target = pn.nodes.value.find((n) => n.id === 'b')!
await pn.setEnabled(target, true)
expect(pn.nodes.value.find((n) => n.id === 'b')!.enabled).toBe(true)
expect(partnerNodesApi.setEnabled).toHaveBeenCalledWith('b', true)
})
it('reports governance refresh failures after a successful toggle', async () => {
mockApplyGovernanceChange.mockRejectedValueOnce(
new Error('policy unavailable')
)
const pn = await setupLoaded()
const target = pn.nodes.value.find((n) => n.id === 'b')!
await pn.setEnabled(target, true)
await vi.waitFor(() =>
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'workspacePanel.partnerNodes.updateError'
})
)
expect(pn.nodes.value.find((n) => n.id === 'b')!.enabled).toBe(true)
})
it('reverts and toasts when a toggle fails', async () => {
const pn = await setupLoaded()
vi.mocked(partnerNodesApi.setEnabled).mockRejectedValueOnce(new Error('x'))
const target = pn.nodes.value.find((n) => n.id === 'a')!
await pn.setEnabled(target, false)
expect(pn.nodes.value.find((n) => n.id === 'a')!.enabled).toBe(true)
expect(mockToastAdd).toHaveBeenCalled()
})
it('bulk-toggles the current selection and clears it on success', async () => {
const pn = await setupLoaded()
pn.toggleSelection('a')
pn.toggleSelection('c')
await pn.setSelectedEnabled(false)
expect(pn.nodes.value.find((n) => n.id === 'a')!.enabled).toBe(false)
expect(pn.nodes.value.find((n) => n.id === 'c')!.enabled).toBe(false)
expect(partnerNodesApi.setEnabledBulk).toHaveBeenCalledWith(
['a', 'c'],
false
)
expect(pn.selectedCount.value).toBe(0)
})
it('does not roll back an unrelated update when a bulk update fails', async () => {
const pn = await setupLoaded()
let rejectBulk!: (reason?: unknown) => void
vi.mocked(partnerNodesApi.setEnabledBulk).mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectBulk = reject
})
)
pn.toggleSelection('a')
pn.toggleSelection('c')
const bulkUpdate = pn.setSelectedEnabled(false)
const unrelatedNode = pn.nodes.value.find((n) => n.id === 'b')!
await pn.setEnabled(unrelatedNode, true)
rejectBulk(new Error('bulk failed'))
await bulkUpdate
expect(pn.nodes.value.find((n) => n.id === 'b')!.enabled).toBe(true)
})
it('does not let an older failed node update undo a newer success', async () => {
const pn = await setupLoaded()
const firstRequest = createDeferred<void>()
vi.mocked(partnerNodesApi.setEnabled)
.mockReturnValueOnce(firstRequest.promise)
.mockResolvedValueOnce()
const firstUpdate = pn.setEnabled(
pn.nodes.value.find((n) => n.id === 'a')!,
false
)
const secondUpdate = pn.setEnabled(
pn.nodes.value.find((n) => n.id === 'a')!,
false
)
firstRequest.reject(new Error('older update failed'))
await Promise.all([firstUpdate, secondUpdate])
expect(pn.nodes.value.find((n) => n.id === 'a')!.enabled).toBe(false)
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('does not let an older failed bulk update undo a newer success', async () => {
const pn = await setupLoaded()
const firstRequest = createDeferred<void>()
vi.mocked(partnerNodesApi.setEnabledBulk)
.mockReturnValueOnce(firstRequest.promise)
.mockResolvedValueOnce()
const firstUpdate = pn.setGroupEnabled(
pn.groups.value.find((group) => group.partner === 'BFL')!,
false
)
const secondUpdate = pn.setGroupEnabled(
pn.groups.value.find((group) => group.partner === 'BFL')!,
false
)
firstRequest.reject(new Error('older update failed'))
await Promise.all([firstUpdate, secondUpdate])
expect(pn.nodes.value.find((n) => n.id === 'a')!.enabled).toBe(false)
expect(pn.nodes.value.find((n) => n.id === 'c')!.enabled).toBe(false)
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('group toggle bulk-toggles every node in the group', async () => {
const pn = await setupLoaded()
const bfl = pn.groups.value.find((g) => g.partner === 'BFL')!
await pn.setGroupEnabled(bfl, false)
expect(pn.nodes.value.find((n) => n.id === 'a')!.enabled).toBe(false)
expect(pn.nodes.value.find((n) => n.id === 'c')!.enabled).toBe(false)
const [ids, enabled] = vi.mocked(partnerNodesApi.setEnabledBulk).mock
.calls[0]
expect([...ids].sort()).toEqual(['a', 'c'])
expect(enabled).toBe(false)
})
it('select-all reflects the filtered set', async () => {
const pn = await setupLoaded()
pn.searchQuery.value = 'BFL'
expect(pn.selectAllState.value).toBe(false)
pn.toggleSelection('a')
expect(pn.selectAllState.value).toBe('indeterminate')
pn.toggleSelectAll()
expect(pn.selectedCount.value).toBe(2)
expect(pn.selectAllState.value).toBe(true)
})
it('keeps selections outside the filtered set when toggling select-all', async () => {
const pn = await setupLoaded()
pn.toggleSelection('b')
pn.searchQuery.value = 'BFL'
pn.toggleSelectAll()
expect([...pn.selectedIds.value].sort()).toEqual(['a', 'b', 'c'])
pn.toggleSelectAll()
expect([...pn.selectedIds.value]).toEqual(['b'])
})
it('bulk-toggles only the filtered set', async () => {
const pn = await setupLoaded()
pn.searchQuery.value = 'Zeta'
await pn.setAllFilteredEnabled(false)
expect(partnerNodesApi.setEnabledBulk).toHaveBeenCalledWith(['a'], false)
expect(pn.nodes.value.find((n) => n.id === 'a')!.enabled).toBe(false)
expect(pn.nodes.value.find((n) => n.id === 'c')!.enabled).toBe(true)
})
it('reverts the auto-enable default when its update fails', async () => {
const pn = await setupLoaded()
vi.mocked(partnerNodesApi.setAutoEnableNew).mockRejectedValueOnce(
new Error('update failed')
)
await pn.setAutoEnableNew(false)
expect(pn.autoEnableNew.value).toBe(true)
expect(mockToastAdd).toHaveBeenCalled()
})
it('does not let an older auto-enable failure undo a newer success', async () => {
const pn = await setupLoaded()
const firstRequest = createDeferred<void>()
vi.mocked(partnerNodesApi.setAutoEnableNew)
.mockReturnValueOnce(firstRequest.promise)
.mockResolvedValueOnce()
const firstUpdate = pn.setAutoEnableNew(false)
const secondUpdate = pn.setAutoEnableNew(false)
firstRequest.reject(new Error('older update failed'))
await Promise.all([firstUpdate, secondUpdate])
expect(pn.autoEnableNew.value).toBe(false)
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('sends overlapping writes to the same node in order', async () => {
const pn = await setupLoaded()
const firstRequest = createDeferred<void>()
const secondRequest = createDeferred<void>()
vi.mocked(partnerNodesApi.setEnabled)
.mockReturnValueOnce(firstRequest.promise)
.mockReturnValueOnce(secondRequest.promise)
const firstUpdate = pn.setEnabled(
pn.nodes.value.find((n) => n.id === 'a')!,
false
)
const secondUpdate = pn.setEnabled(
pn.nodes.value.find((n) => n.id === 'a')!,
true
)
await vi.waitFor(() =>
expect(partnerNodesApi.setEnabled).toHaveBeenCalledTimes(1)
)
firstRequest.resolve()
await firstUpdate
await vi.waitFor(() =>
expect(partnerNodesApi.setEnabled).toHaveBeenCalledTimes(2)
)
secondRequest.resolve()
await secondUpdate
expect(vi.mocked(partnerNodesApi.setEnabled).mock.calls).toEqual([
['a', false],
['a', true]
])
expect(pn.nodes.value.find((n) => n.id === 'a')!.enabled).toBe(true)
})
it('refreshes governance only after the latest node write settles', async () => {
const pn = await setupLoaded()
const firstRequest = createDeferred<void>()
const secondRequest = createDeferred<void>()
vi.mocked(partnerNodesApi.setEnabled)
.mockReturnValueOnce(firstRequest.promise)
.mockReturnValueOnce(secondRequest.promise)
const target = pn.nodes.value.find((n) => n.id === 'b')!
const firstUpdate = pn.setEnabled(target, true)
const secondUpdate = pn.setEnabled(target, false)
firstRequest.resolve()
await firstUpdate
await vi.waitFor(() =>
expect(partnerNodesApi.setEnabled).toHaveBeenCalledTimes(2)
)
expect(mockApplyGovernanceChange).not.toHaveBeenCalled()
secondRequest.resolve()
await secondUpdate
expect(mockApplyGovernanceChange).toHaveBeenCalledOnce()
})
it('refreshes governance only after the latest bulk write settles', async () => {
const pn = await setupLoaded()
const firstRequest = createDeferred<void>()
const secondRequest = createDeferred<void>()
vi.mocked(partnerNodesApi.setEnabledBulk)
.mockReturnValueOnce(firstRequest.promise)
.mockReturnValueOnce(secondRequest.promise)
const group = pn.groups.value.find((item) => item.partner === 'BFL')!
const firstUpdate = pn.setGroupEnabled(group, false)
const secondUpdate = pn.setGroupEnabled(group, true)
firstRequest.resolve()
await firstUpdate
await vi.waitFor(() =>
expect(partnerNodesApi.setEnabledBulk).toHaveBeenCalledTimes(2)
)
expect(mockApplyGovernanceChange).not.toHaveBeenCalled()
secondRequest.resolve()
await secondUpdate
expect(mockApplyGovernanceChange).toHaveBeenCalledOnce()
})
it('restores confirmed state when overlapping writes both fail', async () => {
const pn = await setupLoaded()
const firstRequest = createDeferred<void>()
vi.mocked(partnerNodesApi.setEnabled)
.mockReturnValueOnce(firstRequest.promise)
.mockRejectedValueOnce(new Error('newer update failed'))
const firstUpdate = pn.setEnabled(
pn.nodes.value.find((n) => n.id === 'a')!,
false
)
const secondUpdate = pn.setEnabled(
pn.nodes.value.find((n) => n.id === 'a')!,
true
)
firstRequest.reject(new Error('older update failed'))
await Promise.all([firstUpdate, secondUpdate])
expect(pn.nodes.value.find((n) => n.id === 'a')!.enabled).toBe(true)
expect(mockToastAdd).toHaveBeenCalledTimes(1)
})
it('restores the latest confirmed state when the newer write fails', async () => {
const pn = await setupLoaded()
const firstRequest = createDeferred<void>()
vi.mocked(partnerNodesApi.setEnabled)
.mockReturnValueOnce(firstRequest.promise)
.mockRejectedValueOnce(new Error('newer update failed'))
const firstUpdate = pn.setEnabled(
pn.nodes.value.find((n) => n.id === 'a')!,
false
)
const secondUpdate = pn.setEnabled(
pn.nodes.value.find((n) => n.id === 'a')!,
true
)
firstRequest.resolve()
await Promise.all([firstUpdate, secondUpdate])
expect(pn.nodes.value.find((n) => n.id === 'a')!.enabled).toBe(false)
expect(mockToastAdd).toHaveBeenCalledTimes(1)
})
it('exposes loading and error state when the initial fetch fails', async () => {
vi.mocked(partnerNodesApi.list).mockRejectedValueOnce(
new Error('load failed')
)
const pn = usePartnerNodes()
expect(pn.isLoading.value).toBe(true)
await pn.fetch()
expect(pn.isLoading.value).toBe(false)
expect(pn.loadError.value).toBe(true)
})
})

View File

@@ -0,0 +1,446 @@
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { PartnerNode } from '@/platform/workspace/api/partnerNodesApi'
import { partnerNodesApi } from '@/platform/workspace/api/partnerNodesApi'
import { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
export interface PartnerGroup {
partner: string
nodes: PartnerNode[]
allNodes: PartnerNode[]
enabledCount: number
totalCount: number
lastModified: string | null
expanded: boolean
}
type SortField = 'name' | 'lastModified'
type SortDirection = 'asc' | 'desc'
type NodeMutationState = Pick<PartnerNode, 'enabled' | 'last_modified'>
function compareNodes(
a: PartnerNode,
b: PartnerNode,
field: SortField,
direction: SortDirection
): number {
const dir = direction === 'asc' ? 1 : -1
if (field === 'lastModified') {
const av = a.last_modified ?? ''
const bv = b.last_modified ?? ''
return av.localeCompare(bv) * dir
}
return a.name.localeCompare(b.name) * dir
}
function compareGroups(
a: PartnerGroup,
b: PartnerGroup,
field: SortField,
direction: SortDirection
): number {
const dir = direction === 'asc' ? 1 : -1
if (field === 'lastModified') {
return (a.lastModified ?? '').localeCompare(b.lastModified ?? '') * dir
}
return a.partner.localeCompare(b.partner) * dir
}
export function usePartnerNodes() {
const { t } = useI18n()
const toast = useToast()
const nodes = ref<PartnerNode[]>([])
const autoEnableNew = ref(true)
const isLoading = ref(true)
const loadError = ref(false)
const searchQuery = ref('')
const sortField = ref<SortField>('name')
const sortDirection = ref<SortDirection>('asc')
const selectedIds = ref<Set<string>>(new Set())
const nodeMutationVersions = new Map<string, number>()
const confirmedNodeStates = new Map<string, NodeMutationState>()
const nodeMutationQueues = new Map<string, Promise<void>>()
let autoEnableMutationVersion = 0
let confirmedAutoEnableNew = true
let autoEnableMutationQueue = Promise.resolve()
function enqueueNodeMutation(
ids: string[],
operation: () => Promise<void>
): Promise<void> {
const pending = ids.map(
(id) => nodeMutationQueues.get(id) ?? Promise.resolve()
)
const result = Promise.all(pending).then(operation)
const queueTail = result.catch(() => {})
for (const id of ids) nodeMutationQueues.set(id, queueTail)
void queueTail.then(() => {
for (const id of ids) {
if (nodeMutationQueues.get(id) === queueTail) {
nodeMutationQueues.delete(id)
}
}
})
return result
}
function enqueueAutoEnableMutation(
operation: () => Promise<void>
): Promise<void> {
const result = autoEnableMutationQueue.then(operation)
autoEnableMutationQueue = result.catch(() => {})
return result
}
const filteredNodes = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
const filtered = nodes.value.filter(
(n) =>
!q ||
n.name.toLowerCase().includes(q) ||
n.partner.toLowerCase().includes(q)
)
return filtered.sort((a, b) =>
compareNodes(a, b, sortField.value, sortDirection.value)
)
})
const expandedPartners = ref<Set<string>>(new Set())
const isSearching = computed(() => searchQuery.value.trim().length > 0)
const groups = computed<PartnerGroup[]>(() => {
const allByPartner = new Map<string, PartnerNode[]>()
for (const node of nodes.value) {
const list = allByPartner.get(node.partner)
if (list) list.push(node)
else allByPartner.set(node.partner, [node])
}
const visibleByPartner = new Map<string, PartnerNode[]>()
for (const node of filteredNodes.value) {
const list = visibleByPartner.get(node.partner)
if (list) list.push(node)
else visibleByPartner.set(node.partner, [node])
}
return [...visibleByPartner.entries()]
.map(([partner, visibleNodes]) => {
const allNodes = allByPartner.get(partner) ?? []
return {
partner,
nodes: visibleNodes,
allNodes,
enabledCount: allNodes.filter((n) => n.enabled).length,
totalCount: allNodes.length,
lastModified: allNodes.reduce<string | null>(
(latest, n) =>
n.last_modified && (!latest || n.last_modified > latest)
? n.last_modified
: latest,
null
),
expanded: isSearching.value || expandedPartners.value.has(partner)
}
})
.sort((a, b) => compareGroups(a, b, sortField.value, sortDirection.value))
})
function togglePartnerCollapsed(partner: string) {
const next = new Set(expandedPartners.value)
if (next.has(partner)) next.delete(partner)
else next.add(partner)
expandedPartners.value = next
}
// Tri-state group selection: unchecked/indeterminate -> select the whole
// group, checked -> clear it. Selecting never expands — the group checkbox
// and the selection bar carry the feedback.
function groupSelectionState(group: PartnerGroup): boolean | 'indeterminate' {
const selected = group.nodes.filter((n) => selectedIds.value.has(n.id))
if (selected.length === 0) return false
if (selected.length === group.nodes.length) return true
return 'indeterminate'
}
function toggleGroupSelection(group: PartnerGroup) {
const next = new Set(selectedIds.value)
if (groupSelectionState(group) === true) {
for (const n of group.nodes) next.delete(n.id)
} else {
for (const n of group.nodes) next.add(n.id)
}
selectedIds.value = next
}
const selectedCount = computed(() => selectedIds.value.size)
const selectedEnabled = computed(() => {
const selected = nodes.value.filter((n) => selectedIds.value.has(n.id))
return selected.length > 0 && selected.every((n) => n.enabled)
})
const selectAllState = computed<boolean | 'indeterminate'>(() => {
const selectedCount = filteredNodes.value.filter((node) =>
selectedIds.value.has(node.id)
).length
if (selectedCount === 0) return false
if (selectedCount === filteredNodes.value.length) return true
return 'indeterminate'
})
async function fetch() {
isLoading.value = true
loadError.value = false
try {
const data = await partnerNodesApi.list()
nodes.value = data.partner_nodes
autoEnableNew.value = data.auto_enable_new
confirmedNodeStates.clear()
for (const node of data.partner_nodes) {
confirmedNodeStates.set(node.id, {
enabled: node.enabled,
last_modified: node.last_modified
})
}
confirmedAutoEnableNew = data.auto_enable_new
} catch {
loadError.value = true
toast.add({
severity: 'error',
summary: t('workspacePanel.partnerNodes.loadError')
})
} finally {
isLoading.value = false
}
}
function toggleSort(field: SortField) {
if (sortField.value === field) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = field
sortDirection.value = 'asc'
}
}
function applyEnabled(ids: string[], enabled: boolean) {
const idSet = new Set(ids)
const now = new Date().toISOString()
nodes.value = nodes.value.map((n) =>
idSet.has(n.id) ? { ...n, enabled, last_modified: now } : n
)
}
function startNodeMutation(id: string): number {
const version = (nodeMutationVersions.get(id) ?? 0) + 1
nodeMutationVersions.set(id, version)
return version
}
function refreshGovernanceState() {
void useDisabledPartnerNodesStore()
.applyGovernanceChange()
.catch(() => {
toast.add({
severity: 'error',
summary: t('workspacePanel.partnerNodes.updateError')
})
})
}
async function setEnabled(node: PartnerNode, enabled: boolean) {
const currentNode = nodes.value.find((n) => n.id === node.id) ?? node
const { enabled: prevEnabled, last_modified: prevModified } = currentNode
const mutationVersion = startNodeMutation(node.id)
applyEnabled([node.id], enabled)
const appliedNode = nodes.value.find((n) => n.id === node.id)!
const appliedState = {
enabled: appliedNode.enabled,
last_modified: appliedNode.last_modified
}
try {
await enqueueNodeMutation([node.id], () =>
partnerNodesApi.setEnabled(node.id, enabled)
)
confirmedNodeStates.set(node.id, appliedState)
if (nodeMutationVersions.get(node.id) === mutationVersion) {
refreshGovernanceState()
}
} catch {
if (nodeMutationVersions.get(node.id) === mutationVersion) {
const confirmedState = confirmedNodeStates.get(node.id) ?? {
enabled: prevEnabled,
last_modified: prevModified
}
nodes.value = nodes.value.map((n) =>
n.id === node.id ? { ...n, ...confirmedState } : n
)
toast.add({
severity: 'error',
summary: t('workspacePanel.partnerNodes.updateError')
})
refreshGovernanceState()
}
}
}
async function setNodesEnabled(
ids: string[],
enabled: boolean
): Promise<boolean> {
if (ids.length === 0) return false
const idSet = new Set(ids)
const previous = new Map(
nodes.value
.filter((n) => idSet.has(n.id))
.map((n) => [
n.id,
{ enabled: n.enabled, last_modified: n.last_modified }
])
)
const mutationVersions = new Map(
[...idSet].map((id) => [id, startNodeMutation(id)])
)
applyEnabled(ids, enabled)
const applied = new Map(
nodes.value
.filter((n) => idSet.has(n.id))
.map((n) => [
n.id,
{ enabled: n.enabled, last_modified: n.last_modified }
])
)
try {
await enqueueNodeMutation(ids, () =>
partnerNodesApi.setEnabledBulk(ids, enabled)
)
for (const [id, state] of applied) confirmedNodeStates.set(id, state)
if (
[...mutationVersions].every(
([id, version]) => nodeMutationVersions.get(id) === version
)
) {
refreshGovernanceState()
}
return true
} catch {
const currentIds = new Set(
[...mutationVersions]
.filter(
([id, mutationVersion]) =>
mutationVersion === nodeMutationVersions.get(id)
)
.map(([id]) => id)
)
if (currentIds.size > 0) {
nodes.value = nodes.value.map((n) => {
const previousNode = previous.get(n.id)
const confirmedState = confirmedNodeStates.get(n.id) ?? previousNode
return confirmedState && currentIds.has(n.id)
? { ...n, ...confirmedState }
: n
})
toast.add({
severity: 'error',
summary: t('workspacePanel.partnerNodes.updateError')
})
if (currentIds.size === mutationVersions.size) {
refreshGovernanceState()
}
}
return false
}
}
async function setSelectedEnabled(enabled: boolean) {
const ok = await setNodesEnabled([...selectedIds.value], enabled)
// Clear on success: a kept selection can hide inside collapsed groups
// and silently ride along with the next bulk toggle. On failure the
// selection survives for a retry.
if (ok) clearSelection()
}
async function setAllFilteredEnabled(enabled: boolean) {
await setNodesEnabled(
filteredNodes.value.map((n) => n.id),
enabled
)
}
async function setGroupEnabled(group: PartnerGroup, enabled: boolean) {
await setNodesEnabled(
group.allNodes.map((n) => n.id),
enabled
)
}
async function setAutoEnableNew(value: boolean) {
const mutationVersion = ++autoEnableMutationVersion
autoEnableNew.value = value
try {
await enqueueAutoEnableMutation(() =>
partnerNodesApi.setAutoEnableNew(value)
)
confirmedAutoEnableNew = value
} catch {
if (autoEnableMutationVersion === mutationVersion) {
autoEnableNew.value = confirmedAutoEnableNew
toast.add({
severity: 'error',
summary: t('workspacePanel.partnerNodes.updateError')
})
}
}
}
function toggleSelection(id: string) {
const next = new Set(selectedIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selectedIds.value = next
}
function toggleSelectAll() {
const next = new Set(selectedIds.value)
if (selectAllState.value === true) {
for (const node of filteredNodes.value) next.delete(node.id)
} else {
for (const node of filteredNodes.value) next.add(node.id)
}
selectedIds.value = next
}
function clearSelection() {
selectedIds.value = new Set()
}
return {
nodes,
autoEnableNew,
isLoading,
loadError,
searchQuery,
sortField,
sortDirection,
selectedIds,
selectedCount,
selectedEnabled,
selectAllState,
filteredNodes,
groups,
togglePartnerCollapsed,
groupSelectionState,
toggleGroupSelection,
fetch,
toggleSort,
setEnabled,
setSelectedEnabled,
setAllFilteredEnabled,
setGroupEnabled,
setAutoEnableNew,
toggleSelection,
toggleSelectAll,
clearSelection
}
}

View File

@@ -11,7 +11,7 @@ import { useDialogService } from '@/services/dialogService'
* Builds the Plan & Credits overflow-menu model for the workspace subscription
* panel. Visibility and the Delete enable/disable policy are derived from the
* shared useWorkspaceUI state so this menu can't desync with the sibling
* WorkspacePanelContent menu.
* Plan & Credits panel menu.
*/
export function useWorkspaceMenuItems() {
const { t } = useI18n()

View File

@@ -0,0 +1,17 @@
import { ref } from 'vue'
// Shared inline-rename state so both the header (double-click) and the
// workspace menu ("Rename") drive the same editing affordance.
const isRenaming = ref(false)
export function useWorkspaceRename() {
function startRenaming() {
isRenaming.value = true
}
function stopRenaming() {
isRenaming.value = false
}
return { isRenaming, startRenaming, stopRenaming }
}

View File

@@ -129,7 +129,8 @@ describe('useWorkspaceUI', () => {
canManageInvites: false,
canManageMembers: false,
canLeaveWorkspace: false,
canAccessWorkspaceMenu: false
canAccessWorkspaceMenu: false,
canManagePartnerNodes: false
})
})
@@ -176,6 +177,7 @@ describe('useWorkspaceUI', () => {
canManageMembers: true,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManagePartnerNodes: true,
canManageSubscription: true,
canTopUp: true
})
@@ -209,22 +211,23 @@ describe('useWorkspaceUI', () => {
expect(ui.workspaceRole.value).toBe('member')
expect(ui.permissions.value).toMatchObject({
canViewOtherMembers: true,
canViewPendingInvites: false,
canViewPendingInvites: true,
canInviteMembers: false,
canManageInvites: false,
canManageMembers: false,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManagePartnerNodes: false,
canManageSubscription: false,
canTopUp: false
})
})
it('shows members but hides invite management and uses leave action', async () => {
it('shows members and pending but hides invite management and uses leave action', async () => {
const ui = await loadComposable()
expect(ui.uiConfig.value.showMembersList).toBe(true)
expect(ui.uiConfig.value.showPendingTab).toBe(false)
expect(ui.uiConfig.value.showPendingTab).toBe(true)
expect(ui.uiConfig.value.showEditWorkspaceMenuItem).toBe(false)
expect(ui.uiConfig.value.workspaceMenuAction).toBe('leave')
expect(ui.uiConfig.value.workspaceMenuDisabledTooltip).toBeNull()

View File

@@ -23,6 +23,9 @@ interface WorkspacePermissions {
// commit); only the original owner gets `canManageSubscriptionLifecycle`.
canManageSubscriptionLifecycle: boolean
canTopUp: boolean
// Partner-node governance is workspace-wide and gated to Owner + Admins
// (both hold the backend 'owner' role); Members never see the tab.
canManagePartnerNodes: boolean
}
/** UI configuration for workspace role */
@@ -56,7 +59,8 @@ function getPermissions(
canManageSubscription: true,
// Personal workspace is single-member: the user is the sole owner/creator.
canManageSubscriptionLifecycle: true,
canTopUp: true
canTopUp: true,
canManagePartnerNodes: false
}
}
@@ -71,14 +75,17 @@ function getPermissions(
canAccessWorkspaceMenu: true,
canManageSubscription: true,
canManageSubscriptionLifecycle: isOriginalOwner,
canTopUp: true
canTopUp: true,
canManagePartnerNodes: true
}
}
// member role
return {
canViewOtherMembers: true,
canViewPendingInvites: false,
// Members can see who's been invited (view-only); they still can't
// resend/revoke (canManageInvites) or invite (canInviteMembers).
canViewPendingInvites: true,
canInviteMembers: false,
canManageInvites: false,
canManageMembers: false,
@@ -86,7 +93,8 @@ function getPermissions(
canAccessWorkspaceMenu: true,
canManageSubscription: false,
canManageSubscriptionLifecycle: false,
canTopUp: false
canTopUp: false,
canManagePartnerNodes: false
}
}
@@ -128,7 +136,7 @@ function getUIConfig(
// member role
return {
showMembersList: true,
showPendingTab: false,
showPendingTab: true,
showSearch: true,
showRoleColumn: true,
membersGridCols: 'grid-cols-[1fr_auto]',

View File

@@ -0,0 +1,416 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { PartnerNode } from '@/platform/workspace/api/partnerNodesApi'
import { partnerNodesApi } from '@/platform/workspace/api/partnerNodesApi'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
createMockLGraph,
createMockLGraphNode
} from '@/utils/__tests__/litegraphTestUtils'
const mockAppState = vi.hoisted(() => ({
isGraphReady: false,
rootGraph: null as unknown
}))
const mockGraphChangedListeners = vi.hoisted(() => new Set<() => void>())
const devPartnerNodeType = 'DevPartnerNode'
const extensionHiddenNodeType = 'ExtensionHiddenPartnerNode'
const mockFeatureFlags = vi.hoisted(() => ({
teamWorkspacesEnabled: true,
partnerNodeGovernanceEnabled: true
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: mockFeatureFlags })
}))
vi.mock('@/i18n', () => ({
st: (_key: string, fallback: string) => fallback,
t: (key: string) => key
}))
vi.mock('@/platform/workspace/api/partnerNodesApi', () => ({
partnerNodesApi: { list: vi.fn() }
}))
vi.mock('@/scripts/app', () => ({
app: mockAppState
}))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn((_type: string, listener: () => void) => {
mockGraphChangedListeners.add(listener)
}),
removeEventListener: vi.fn((_type: string, listener: () => void) => {
mockGraphChangedListeners.delete(listener)
})
}
}))
function partnerNode(overrides: Partial<PartnerNode>): PartnerNode {
return {
id: 'StableNodeId',
name: 'Display name',
partner: 'Provider',
last_modified: null,
enabled: true,
...overrides
}
}
function nodeDef(name: string, apiNode = true) {
return { name, display_name: `Display ${name}`, api_node: apiNode }
}
function completeNodeDef(name: string, devOnly = false): ComfyNodeDef {
return {
name,
display_name: `Display ${name}`,
category: 'api/provider',
python_module: 'comfy_api_nodes.provider',
description: '',
input: {},
output: [],
output_is_list: [],
output_name: [],
output_node: false,
api_node: true,
dev_only: devOnly
}
}
function activateWorkspace(id: string, type: 'personal' | 'team') {
const store = useTeamWorkspaceStore()
store.workspaces = [
{
id,
name: id,
type,
role: 'owner',
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z',
isSubscribed: false,
subscriptionPlan: null,
subscriptionTier: null,
members: [],
pendingInvites: []
}
]
store.activeWorkspaceId = id
}
describe('disabledPartnerNodesStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.resetAllMocks()
activateWorkspace('team-workspace', 'team')
mockAppState.isGraphReady = false
mockAppState.rootGraph = null
mockGraphChangedListeners.clear()
mockFeatureFlags.teamWorkspacesEnabled = true
mockFeatureFlags.partnerNodeGovernanceEnabled = true
})
afterEach(() => {
vi.useRealTimers()
if (LiteGraph.registered_node_types[devPartnerNodeType]) {
LiteGraph.unregisterNodeType(devPartnerNodeType)
}
if (LiteGraph.registered_node_types[extensionHiddenNodeType]) {
LiteGraph.unregisterNodeType(extensionHiddenNodeType)
}
})
it('allows only positively verified node type IDs', async () => {
vi.mocked(partnerNodesApi.list).mockResolvedValue({
partner_nodes: [
partnerNode({ id: 'EnabledNode', enabled: true }),
partnerNode({ id: 'DisabledNode', enabled: false })
],
auto_enable_new: false
})
const store = useDisabledPartnerNodesStore()
await store.applyGovernanceChange()
expect(store.isNodeDefDisabled(nodeDef('EnabledNode'))).toBe(false)
expect(store.isNodeDefDisabled(nodeDef('DisabledNode'))).toBe(true)
expect(store.isNodeDefDisabled(nodeDef('NewCatalogNode'))).toBe(true)
expect(store.isNodeDefDisabled(nodeDef('CoreNode', false))).toBe(false)
})
it('fails closed before and after policy verification fails', async () => {
vi.mocked(partnerNodesApi.list).mockRejectedValue(
new Error('policy unavailable')
)
const store = useDisabledPartnerNodesStore()
expect(store.isNodeDefDisabled(nodeDef('PartnerNode'))).toBe(true)
await expect(store.applyGovernanceChange()).rejects.toThrow(
'Failed to refresh Partner Node governance policy'
)
expect(store.policyState).toBe('failed')
expect(store.isNodeDefDisabled(nodeDef('PartnerNode'))).toBe(true)
})
it('fails closed during same-workspace policy refreshes', async () => {
let resolveRefresh!: (response: {
partner_nodes: PartnerNode[]
auto_enable_new: boolean
}) => void
vi.mocked(partnerNodesApi.list)
.mockResolvedValueOnce({
partner_nodes: [partnerNode({ id: 'PartnerNode', enabled: true })],
auto_enable_new: false
})
.mockReturnValueOnce(
new Promise((resolve) => {
resolveRefresh = resolve
})
)
const store = useDisabledPartnerNodesStore()
await store.applyGovernanceChange()
const refresh = store.applyGovernanceChange()
await vi.waitFor(() =>
expect(partnerNodesApi.list).toHaveBeenCalledTimes(2)
)
expect(store.policyState).toBe('loading')
expect(store.isNodeDefDisabled(nodeDef('PartnerNode'))).toBe(true)
resolveRefresh({
partner_nodes: [partnerNode({ id: 'PartnerNode', enabled: true })],
auto_enable_new: false
})
await refresh
expect(store.policyState).toBe('verified')
expect(store.isNodeDefDisabled(nodeDef('PartnerNode'))).toBe(false)
})
it('does not apply governance to personal workspaces', async () => {
activateWorkspace('personal-workspace', 'personal')
const store = useDisabledPartnerNodesStore()
await store.applyGovernanceChange()
expect(partnerNodesApi.list).not.toHaveBeenCalled()
expect(store.policyState).toBe('inactive')
expect(store.isNodeDefDisabled(nodeDef('PartnerNode'))).toBe(false)
})
it('does not enforce governance before rollout is enabled', async () => {
mockFeatureFlags.partnerNodeGovernanceEnabled = false
const store = useDisabledPartnerNodesStore()
await store.applyGovernanceChange()
expect(partnerNodesApi.list).not.toHaveBeenCalled()
expect(store.policyState).toBe('inactive')
expect(store.isNodeDefDisabled(nodeDef('PartnerNode'))).toBe(false)
})
it('composes governance and dev-only discovery visibility', async () => {
vi.mocked(partnerNodesApi.list)
.mockResolvedValueOnce({
partner_nodes: [partnerNode({ id: devPartnerNodeType })],
auto_enable_new: false
})
.mockResolvedValueOnce({
partner_nodes: [
partnerNode({ id: devPartnerNodeType, enabled: false })
],
auto_enable_new: false
})
const nodeDefStore = useNodeDefStore()
nodeDefStore.updateNodeDefs([completeNodeDef(devPartnerNodeType, true)])
class DevPartnerNode extends LGraphNode {}
DevPartnerNode.nodeData = nodeDefStore.nodeDefsByName[devPartnerNodeType]
LiteGraph.registerNodeType(devPartnerNodeType, DevPartnerNode)
const store = useDisabledPartnerNodesStore()
await store.applyGovernanceChange()
expect(DevPartnerNode.skip_list).toBe(true)
useSettingStore().settingValues['Comfy.DevMode'] = true
await nextTick()
expect(DevPartnerNode.skip_list).toBe(false)
await store.applyGovernanceChange()
useSettingStore().settingValues['Comfy.DevMode'] = false
await nextTick()
useSettingStore().settingValues['Comfy.DevMode'] = true
await nextTick()
expect(DevPartnerNode.skip_list).toBe(true)
})
it('preserves extension-owned discovery visibility', async () => {
vi.mocked(partnerNodesApi.list).mockResolvedValue({
partner_nodes: [partnerNode({ id: extensionHiddenNodeType })],
auto_enable_new: false
})
const nodeDefStore = useNodeDefStore()
nodeDefStore.updateNodeDefs([completeNodeDef(extensionHiddenNodeType)])
class ExtensionHiddenPartnerNode extends LGraphNode {}
ExtensionHiddenPartnerNode.nodeData =
nodeDefStore.nodeDefsByName[extensionHiddenNodeType]
LiteGraph.registerNodeType(
extensionHiddenNodeType,
ExtensionHiddenPartnerNode
)
ExtensionHiddenPartnerNode.skip_list = true
const store = useDisabledPartnerNodesStore()
await store.applyGovernanceChange()
expect(ExtensionHiddenPartnerNode.skip_list).toBe(true)
})
it('surfaces disabled canvas nodes as policy offenders', async () => {
vi.mocked(partnerNodesApi.list).mockResolvedValue({
partner_nodes: [partnerNode({ id: 'AllowedNode', enabled: true })],
auto_enable_new: false
})
useNodeDefStore().updateNodeDefs([
completeNodeDef('AllowedNode'),
completeNodeDef('DisabledNode')
])
const graphNode = createMockLGraphNode({ type: 'DisabledNode' })
const graph = createMockLGraph({ nodes: [graphNode], isRootGraph: true })
graphNode.graph = graph
mockAppState.isGraphReady = true
mockAppState.rootGraph = graph
const store = useDisabledPartnerNodesStore()
await store.applyGovernanceChange()
expect(store.offenders).toEqual([
expect.objectContaining({
nodeId: String(graphNode.id),
displayName: 'Display DisabledNode'
})
])
})
it('fails closed while loading and reconciles after verification', async () => {
let resolvePolicy!: (response: {
partner_nodes: PartnerNode[]
auto_enable_new: boolean
}) => void
vi.mocked(partnerNodesApi.list).mockReturnValue(
new Promise((resolve) => {
resolvePolicy = resolve
})
)
useNodeDefStore().updateNodeDefs([completeNodeDef('PartnerNode')])
const graphNode = createMockLGraphNode({ type: 'PartnerNode' })
const graph = createMockLGraph({ nodes: [graphNode], isRootGraph: true })
graphNode.graph = graph
mockAppState.isGraphReady = true
mockAppState.rootGraph = graph
const store = useDisabledPartnerNodesStore()
const surface = store.surfaceDisabledNodes({ silent: true })
await vi.waitFor(() => expect(partnerNodesApi.list).toHaveBeenCalled())
expect(store.policyState).toBe('loading')
expect(store.offenders).toHaveLength(1)
resolvePolicy({
partner_nodes: [partnerNode({ id: 'PartnerNode', enabled: true })],
auto_enable_new: false
})
await surface
expect(store.policyState).toBe('verified')
expect(store.offenders).toEqual([])
})
it('notifies when a loaded workflow contains disabled nodes', async () => {
vi.mocked(partnerNodesApi.list).mockResolvedValue({
partner_nodes: [],
auto_enable_new: false
})
useNodeDefStore().updateNodeDefs([completeNodeDef('DisabledNode')])
const graphNode = createMockLGraphNode({ type: 'DisabledNode' })
const graph = createMockLGraph({ nodes: [graphNode], isRootGraph: true })
graphNode.graph = graph
mockAppState.isGraphReady = true
mockAppState.rootGraph = graph
const store = useDisabledPartnerNodesStore()
await store.surfaceDisabledNodes()
expect(useToastStore().messagesToAdd).toEqual([
expect.objectContaining({ severity: 'error', group: 'disabled-nodes' })
])
})
it('refreshes and replaces policy when the active workspace changes', async () => {
vi.mocked(partnerNodesApi.list)
.mockResolvedValueOnce({
partner_nodes: [partnerNode({ id: 'FirstNode', enabled: true })],
auto_enable_new: false
})
.mockResolvedValueOnce({
partner_nodes: [partnerNode({ id: 'SecondNode', enabled: true })],
auto_enable_new: false
})
const store = useDisabledPartnerNodesStore()
await store.applyGovernanceChange()
activateWorkspace('second-team-workspace', 'team')
await vi.waitFor(() =>
expect(partnerNodesApi.list).toHaveBeenCalledTimes(2)
)
await vi.waitFor(() =>
expect(store.isNodeDefDisabled(nodeDef('SecondNode'))).toBe(false)
)
expect(store.isNodeDefDisabled(nodeDef('FirstNode'))).toBe(true)
})
it('rescans after nodes are added or removed', async () => {
vi.useFakeTimers()
vi.mocked(partnerNodesApi.list).mockResolvedValue({
partner_nodes: [partnerNode({ id: 'AllowedNode', enabled: true })],
auto_enable_new: false
})
useNodeDefStore().updateNodeDefs([completeNodeDef('DisabledNode')])
const graph = createMockLGraph({ nodes: [], isRootGraph: true })
mockAppState.isGraphReady = true
mockAppState.rootGraph = graph
const store = useDisabledPartnerNodesStore()
await store.applyGovernanceChange()
const graphNode = createMockLGraphNode({ type: 'DisabledNode' })
graphNode.graph = graph
graph.nodes.push(graphNode)
mockGraphChangedListeners.forEach((listener) => listener())
await vi.advanceTimersByTimeAsync(250)
expect(store.offenders).toHaveLength(1)
graph.nodes.splice(graph.nodes.indexOf(graphNode), 1)
mockGraphChangedListeners.forEach((listener) => listener())
await vi.advanceTimersByTimeAsync(250)
expect(store.offenders).toEqual([])
})
})

View File

@@ -0,0 +1,235 @@
import { debounce } from 'es-toolkit'
import { defineStore } from 'pinia'
import { computed, onScopeDispose, ref, watch } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { st, t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { partnerNodesApi } from '@/platform/workspace/api/partnerNodesApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
import { forEachNode, getExecutionIdByNode } from '@/utils/graphTraversalUtil'
import { setNodeTypeHidden } from '@/utils/litegraphNodeVisibility'
export interface DisabledGraphNode {
nodeId: NodeExecutionId
displayName: string
}
type PolicyState = 'inactive' | 'loading' | 'verified' | 'failed'
type PolicyRefreshResult = 'success' | 'superseded' | 'failed'
/**
* Enforcement side of partner-node governance: which allowlist-disabled
* partner nodes are present in the open workflow. Feeds the Workflow
* Overview error panel, canvas error flags, run gating, and the load toast.
*/
export const useDisabledPartnerNodesStore = defineStore(
'disabledPartnerNodes',
() => {
const enabledNodeIds = ref<Set<string>>(new Set())
const offenders = ref<DisabledGraphNode[]>([])
const policyState = ref<PolicyState>('inactive')
const policyWorkspaceId = ref<string | null>(null)
const { flags } = useFeatureFlags()
const workspaceStore = useTeamWorkspaceStore()
let refreshVersion = 0
const governedWorkspaceId = computed(() => {
const workspace = workspaceStore.activeWorkspace
return flags.teamWorkspacesEnabled &&
flags.partnerNodeGovernanceEnabled &&
workspace?.type === 'team'
? workspace.id
: null
})
function isNodeDefDisabled(
def: Pick<ComfyNodeDefImpl, 'name' | 'api_node'>
): boolean {
if (!def.api_node) return false
const workspaceId = governedWorkspaceId.value
if (!workspaceId) return false
if (
policyState.value !== 'verified' ||
policyWorkspaceId.value !== workspaceId
) {
return true
}
return !enabledNodeIds.value.has(def.name)
}
let nodeDefFilterRegistered = false
function ensureNodeDefFilter(): void {
if (nodeDefFilterRegistered) return
useNodeDefStore().registerNodeDefFilter({
id: 'workspace.disabled-partner-nodes',
name: st(
'nodeFilters.hideDisabledPartnerNodes',
'Hide Admin-Disabled Partner Nodes'
),
predicate: (def) => !isNodeDefDisabled(def)
})
nodeDefFilterRegistered = true
}
const disabledAncestorExecutionIds = computed<Set<NodeExecutionId>>(() => {
const ids = new Set<NodeExecutionId>()
for (const offender of offenders.value) {
for (const id of getAncestorExecutionIds(offender.nodeId)) {
ids.add(id)
}
}
return ids
})
async function refreshPolicy(): Promise<PolicyRefreshResult> {
const workspaceId = governedWorkspaceId.value
const version = ++refreshVersion
if (!workspaceId) {
enabledNodeIds.value = new Set()
policyWorkspaceId.value = null
policyState.value = 'inactive'
return 'success'
}
if (policyWorkspaceId.value !== workspaceId) {
enabledNodeIds.value = new Set()
}
policyState.value = 'loading'
try {
const { partner_nodes } = await partnerNodesApi.list()
if (
version !== refreshVersion ||
governedWorkspaceId.value !== workspaceId
) {
return 'superseded'
}
enabledNodeIds.value = new Set(
partner_nodes.filter((node) => node.enabled).map((node) => node.id)
)
policyWorkspaceId.value = workspaceId
policyState.value = 'verified'
return 'success'
} catch {
if (
version !== refreshVersion ||
governedWorkspaceId.value !== workspaceId
) {
return 'superseded'
}
enabledNodeIds.value = new Set()
policyWorkspaceId.value = workspaceId
policyState.value = 'failed'
return 'failed'
}
}
// The legacy right-click Add Node menu and litegraph palette read
// skip_list off the registered types, so discovery filtering updates both.
function syncLitegraphSkipList(): void {
const nodeDefStore = useNodeDefStore()
const devModeEnabled = useSettingStore().get('Comfy.DevMode')
for (const def of Object.values(nodeDefStore.nodeDefsByName)) {
if (!def.api_node) continue
const nodeType = LiteGraph.registered_node_types[def.name]
if (!nodeType) continue
setNodeTypeHidden(nodeType, 'dev-only', def.dev_only && !devModeEnabled)
setNodeTypeHidden(
nodeType,
'workspace-governance',
isNodeDefDisabled(def)
)
}
}
const scheduleRescan = debounce(scanGraph, 250)
api.addEventListener('graphChanged', scheduleRescan)
onScopeDispose(() => {
api.removeEventListener('graphChanged', scheduleRescan)
scheduleRescan.cancel()
})
function scanGraph(): void {
ensureNodeDefFilter()
syncLitegraphSkipList()
if (!app.isGraphReady) {
offenders.value = []
return
}
const nodeDefStore = useNodeDefStore()
const found: DisabledGraphNode[] = []
forEachNode(app.rootGraph, (node) => {
if (!node.type) return
const def = nodeDefStore.nodeDefsByName[node.type]
if (!def || !isNodeDefDisabled(def)) return
const execId = getExecutionIdByNode(app.rootGraph, node)
if (!execId) return
found.push({
nodeId: execId,
displayName: def.display_name || def.name
})
})
offenders.value = found.sort((a, b) =>
a.displayName.localeCompare(b.displayName)
)
}
/** Workflow-load entry point: refresh governance, scan, announce. */
async function surfaceDisabledNodes(
options: { silent?: boolean } = {}
): Promise<void> {
const refresh = refreshPolicy()
scanGraph()
const result = await refresh
scanGraph()
if (result === 'superseded') return
if (options.silent || offenders.value.length === 0) return
useToastStore().add({
severity: 'error',
group: 'disabled-nodes',
summary: t(
'rightSidePanel.disabledNodes.title',
offenders.value.length
),
detail: t(
'rightSidePanel.disabledNodes.toastDetail',
offenders.value.length
),
life: 10000
})
}
/** Settings-panel toggles call this so an open workflow updates live. */
async function applyGovernanceChange(): Promise<void> {
const result = await refreshPolicy()
if (result === 'failed') {
throw new Error('Failed to refresh Partner Node governance policy')
}
if (result === 'superseded') return
scanGraph()
}
watch(governedWorkspaceId, async () => {
await surfaceDisabledNodes({ silent: true })
})
return {
offenders,
policyState,
disabledAncestorExecutionIds,
isNodeDefDisabled,
scanGraph,
surfaceDisabledNodes,
applyGovernanceChange
}
}
)

View File

@@ -1463,8 +1463,8 @@ describe('useTeamWorkspaceStore', () => {
expect(store.isInviteLimitReached).toBe(false)
})
it('isInviteLimitReached enforces the flat 30-member backend cap, independent of plan seats', async () => {
const mockMembers = Array.from({ length: 28 }, (_, i) => ({
it('isInviteLimitReached enforces the flat 50-member backend cap, independent of plan seats', async () => {
const mockMembers = Array.from({ length: 48 }, (_, i) => ({
id: `user-${i}`,
name: `User ${i}`,
email: `user${i}@test.com`,
@@ -1488,7 +1488,7 @@ describe('useTeamWorkspaceStore', () => {
]
mockWorkspaceApi.listMembers.mockResolvedValue({
members: mockMembers,
pagination: { offset: 0, limit: 50, total: 28 }
pagination: { offset: 0, limit: 50, total: 48 }
})
mockWorkspaceApi.listInvites.mockResolvedValue({ invites: mockInvites })
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
@@ -1499,7 +1499,7 @@ describe('useTeamWorkspaceStore', () => {
await store.fetchMembers()
await store.fetchPendingInvites()
expect(store.totalMemberSlots).toBe(30)
expect(store.totalMemberSlots).toBe(50)
expect(store.isInviteLimitReached).toBe(true)
})
})

View File

@@ -23,6 +23,8 @@ export interface WorkspaceMember {
joinDate: Date
role: 'owner' | 'member'
isOriginalOwner: boolean
lastActivity?: Date | null
creditsUsedThisMonth?: number
}
export interface PendingInvite {
@@ -51,7 +53,11 @@ function mapApiMemberToWorkspaceMember(member: Member): WorkspaceMember {
email: member.email,
joinDate: new Date(member.joined_at),
role: member.role,
isOriginalOwner: member.is_original_owner ?? false
isOriginalOwner: member.is_original_owner ?? false,
lastActivity: member.last_active_at
? new Date(member.last_active_at)
: null,
creditsUsedThisMonth: member.credits_used_this_month ?? 0
}
}
@@ -112,7 +118,7 @@ function clearLastWorkspaceId(): void {
}
const MAX_OWNED_WORKSPACES = 10
export const MAX_WORKSPACE_MEMBERS = 30
export const MAX_WORKSPACE_MEMBERS = 50
const MAX_INIT_RETRIES = 3
const BASE_RETRY_DELAY_MS = 1000

View File

@@ -0,0 +1,21 @@
// Muted, low-saturation badge palette (sampled from the Figma usage palette).
// Dark tones that read against the dark surface with a white monogram.
const BADGE_COLORS = [
'#956252', // terracotta
'#3e465f', // slate indigo
'#424f45', // olive green
'#90646e', // mauve rose
'#6d5a7a', // muted purple
'#4f6b6b', // muted teal
'#7a6a4a', // khaki
'#5a6270' // steel
]
/** Stable muted badge color for a user, keyed by name/email. */
export function userBadgeColor(seed: string): string {
let hash = 0
for (let i = 0; i < seed.length; i++) {
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0
}
return BADGE_COLORS[hash % BADGE_COLORS.length]
}

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { formatRelativeTime } from './relativeTime'
const labels = {
justNow: 'just now',
minutesAgo: (n: number) => `${n} min ago`,
hoursAgo: (n: number) => `${n} hr ago`,
daysAgo: (n: number) => `${n} days ago`
}
const now = new Date('2026-07-03T12:00:00Z')
const ago = (ms: number) => new Date(now.getTime() - ms)
describe('formatRelativeTime', () => {
it('returns "just now" under a minute', () => {
expect(formatRelativeTime(ago(30 * 1000), now, labels)).toBe('just now')
})
it('floors to whole minutes', () => {
expect(formatRelativeTime(ago(6.9 * 60 * 1000), now, labels)).toBe(
'6 min ago'
)
})
it('floors to whole hours', () => {
expect(formatRelativeTime(ago(2 * 60 * 60 * 1000), now, labels)).toBe(
'2 hr ago'
)
})
it('floors to whole days past 24h', () => {
expect(formatRelativeTime(ago(3 * 24 * 60 * 60 * 1000), now, labels)).toBe(
'3 days ago'
)
})
it.for([
{ elapsed: 60 * 1000, expected: '1 min ago' },
{ elapsed: 60 * 60 * 1000, expected: '1 hr ago' },
{ elapsed: 24 * 60 * 60 * 1000, expected: '1 days ago' }
])(
'uses the next unit at the $elapsed ms boundary',
({ elapsed, expected }) => {
expect(formatRelativeTime(ago(elapsed), now, labels)).toBe(expected)
}
)
it('clamps future dates to "just now"', () => {
expect(formatRelativeTime(ago(-5000), now, labels)).toBe('just now')
})
})

Some files were not shown because too many files have changed in this diff Show More