Compare commits

...

1 Commits

Author SHA1 Message Date
comfydesigner
22627df289 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-10 18:30:09 -07:00
12 changed files with 1000 additions and 11 deletions

View File

@@ -54,13 +54,11 @@ const config: KnipConfig = {
'.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Pending integration in the workspace-settings stacked PRs: consumed by
// split/auto-reload + split/allowlist (Switch) and split/member-auditing
// + split/allowlist (Pagination); each consumer removes its entry
'src/components/ui/switch/Switch.vue',
// Pending integration: consumed by split/member-auditing (Activity pager);
// models governance (DES-503) reuses it later
'src/components/ui/pagination/Pagination.vue',
// Pending integration: consumed by split/plan-credits-tabs (Overview) and
// split/allowlist (Models); each consumer removes this entry
// Pending integration: consumed by split/plan-credits-tabs (Overview);
// models governance (DES-503) reuses it later
'src/platform/workspace/composables/useAutoPageSize.ts',
// Marketing media tooling — adopted by pages in a follow-up PR
'apps/website/src/components/common/SiteVideo.vue',

View File

@@ -0,0 +1,31 @@
<template>
<CheckboxRoot
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 { CheckboxIndicator, CheckboxRoot } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
const checked = defineModel<boolean | 'indeterminate'>({ default: false })
</script>

View File

@@ -3059,6 +3059,32 @@
"topSpenders": "Top spenders",
"user": "User"
}
},
"allowlist": {
"disableAll": "Disable all",
"enableAll": "Enable all",
"tabs": {
"partnerNodes": "Partner nodes"
}
},
"partnerNodes": {
"autoEnableSubject": "newly added partner nodes",
"autoEnableVerb": "auto-enable",
"clearSelection": "Clear selection",
"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.",
"groupCount": "{enabled}/{total} enabled",
"loadError": "Failed to load partner nodes",
"neverModified": "—",
"searchPlaceholder": "Search partner nodes",
"selectAll": "Select all partner nodes",
"selectedCount": "{count} node selected | {count} nodes selected",
"updateError": "Failed to update partner nodes"
}
},
"teamWorkspacesDialog": {

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
@@ -200,10 +203,28 @@ export function useSettingUI(
)
}
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',
@@ -258,6 +279,7 @@ export function useSettingUI(
creditsPanel,
userPanel,
...(shouldShowWorkspacePanel.value ? [workspacePanel, membersPanel] : []),
...(shouldShowPartnerNodesPanel.value ? [partnerNodesPanel] : []),
keybindingPanel,
extensionPanel,
...(isDesktop ? [serverConfigPanel] : []),
@@ -310,6 +332,7 @@ export function useSettingUI(
...(shouldShowWorkspacePanel.value
? [workspacePanel.node, membersPanel.node]
: []),
...(shouldShowPartnerNodesPanel.value ? [partnerNodesPanel.node] : []),
...(isLoggedIn.value &&
!(isCloud && window.__CONFIG__?.subscription_required)
? [creditsPanel.node]

View File

@@ -0,0 +1,89 @@
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 {
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 SetEnabledPayload {
enabled: 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 = {
/** GET /api/workspace/partner-nodes */
async list(): Promise<PartnerNodesResponse> {
const headers = await authHeader()
const response = await partnerNodesApiClient.get<PartnerNodesResponse>(
api.apiURL('/workspace/partner-nodes'),
{ headers }
)
return response.data
},
/** PATCH /api/workspace/partner-nodes/:id */
async setEnabled(nodeId: string, enabled: boolean): Promise<void> {
const headers = await authHeader()
const payload: SetEnabledPayload = { enabled }
await partnerNodesApiClient.patch(
api.apiURL(`/workspace/partner-nodes/${nodeId}`),
payload,
{ headers }
)
},
/** PATCH /api/workspace/partner-nodes (bulk) */
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 }
)
},
/** PUT /api/workspace/partner-nodes/settings */
async setAutoEnableNew(autoEnableNew: boolean): Promise<void> {
const headers = await authHeader()
const payload: SetAutoEnablePayload = { auto_enable_new: autoEnableNew }
await partnerNodesApiClient.put(
api.apiURL('/workspace/partner-nodes/settings'),
payload,
{ headers }
)
}
}

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,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,318 @@
<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"
@click="setAllFilteredEnabled(true)"
>
{{ $t('workspacePanel.allowlist.enableAll') }}
</Button>
<Button
variant="muted-textonly"
size="lg"
@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="allFilteredSelected"
:aria-label="$t('workspacePanel.partnerNodes.selectAll')"
@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">
<!-- Provider row: click to expand/collapse its nodes -->
<TableRow
class="cursor-pointer hover:bg-transparent [&:hover>td]:bg-secondary-background/50 [&>td]:border-b [&>td]:border-interface-stroke/20 [&>td]:transition-colors"
:aria-expanded="group.expanded"
@click="togglePartnerCollapsed(group.partner)"
>
<TableCell @click.stop>
<Checkbox
:model-value="groupSelectionState(group)"
:aria-label="group.partner"
@update:model-value="toggleGroupSelection(group)"
/>
</TableCell>
<TableCell>
<div class="flex items-center gap-2">
<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>
</div>
</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" @click.stop>
<!-- 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"
@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 cursor-pointer 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"
@click="toggleSelection(node.id)"
>
<TableCell>
<Checkbox
:model-value="selectedIds.has(node.id)"
:aria-label="node.name"
:class="
cn(
'pointer-events-none',
!hasSelection &&
'opacity-0 transition-opacity group-hover:opacity-100'
)
"
/>
</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" @click.stop>
<Switch
:model-value="node.enabled"
@update:model-value="(v: boolean) => setEnabled(node, v)"
/>
</TableCell>
</TableRow>
</template>
</template>
<TableRow v-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"
@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="bulkEnabled" @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,
searchQuery,
sortField,
sortDirection,
selectedIds,
selectedCount,
allFilteredSelected,
filteredNodes,
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 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'
}
// When every selected node is enabled the bulk switch reads "on", so a toggle
// disables the whole selection; otherwise it enables them.
const bulkEnabled = computed(() =>
filteredNodes.value
.filter((n) => selectedIds.value.has(n.id))
.every((n) => n.enabled)
)
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,134 @@
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()
}
}))
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 })
]
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()
})
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('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('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('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'
pn.toggleSelectAll()
expect(pn.selectedCount.value).toBe(2)
expect(pn.allFilteredSelected.value).toBe(true)
})
})

View File

@@ -0,0 +1,285 @@
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'
export interface PartnerGroup {
partner: string
nodes: PartnerNode[]
enabledCount: number
totalCount: number
lastModified: string | null
expanded: boolean
}
type SortField = 'name' | 'partner' | 'lastModified'
type SortDirection = 'asc' | 'desc'
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
}
const key = field === 'partner' ? 'partner' : 'name'
return a[key].localeCompare(b[key]) * dir
}
export function usePartnerNodes() {
const { t } = useI18n()
const toast = useToast()
const nodes = ref<PartnerNode[]>([])
const autoEnableNew = ref(true)
const isLoading = ref(false)
const searchQuery = ref('')
const sortField = ref<SortField>('name')
const sortDirection = ref<SortDirection>('asc')
const selectedIds = ref<Set<string>>(new Set())
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)
)
})
// Nodes grouped by provider; groups sort alphabetically, children follow the
// active column sort. Groups start collapsed; searching overrides collapse
// so matches are never hidden.
const expandedPartners = ref<Set<string>>(new Set())
const isSearching = computed(() => searchQuery.value.trim().length > 0)
const groups = computed<PartnerGroup[]>(() => {
const byPartner = new Map<string, PartnerNode[]>()
for (const node of filteredNodes.value) {
const list = byPartner.get(node.partner)
if (list) list.push(node)
else byPartner.set(node.partner, [node])
}
return [...byPartner.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([partner, nodes]) => ({
partner,
nodes,
enabledCount: nodes.filter((n) => n.enabled).length,
totalCount: nodes.length,
lastModified: nodes.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)
}))
})
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 allFilteredSelected = computed(
() =>
filteredNodes.value.length > 0 &&
filteredNodes.value.every((n) => selectedIds.value.has(n.id))
)
async function fetch() {
isLoading.value = true
try {
const data = await partnerNodesApi.list()
nodes.value = data.partner_nodes
autoEnableNew.value = data.auto_enable_new
} catch {
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
)
}
async function setEnabled(node: PartnerNode, enabled: boolean) {
const { enabled: prevEnabled, last_modified: prevModified } = node
applyEnabled([node.id], enabled)
try {
await partnerNodesApi.setEnabled(node.id, enabled)
} catch {
nodes.value = nodes.value.map((n) =>
n.id === node.id
? { ...n, enabled: prevEnabled, last_modified: prevModified }
: n
)
toast.add({
severity: 'error',
summary: t('workspacePanel.partnerNodes.updateError')
})
}
}
async function setNodesEnabled(
ids: string[],
enabled: boolean
): Promise<boolean> {
if (ids.length === 0) return false
const previous = new Map(
nodes.value.map((n) => [
n.id,
{ enabled: n.enabled, last_modified: n.last_modified }
])
)
applyEnabled(ids, enabled)
try {
await partnerNodesApi.setEnabledBulk(ids, enabled)
return true
} catch {
nodes.value = nodes.value.map((n) =>
previous.has(n.id) ? { ...n, ...previous.get(n.id)! } : n
)
toast.add({
severity: 'error',
summary: t('workspacePanel.partnerNodes.updateError')
})
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.nodes.map((n) => n.id),
enabled
)
}
async function setAutoEnableNew(value: boolean) {
const previous = autoEnableNew.value
autoEnableNew.value = value
try {
await partnerNodesApi.setAutoEnableNew(value)
} catch {
autoEnableNew.value = previous
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() {
if (allFilteredSelected.value) {
clearSelection()
return
}
selectedIds.value = new Set(filteredNodes.value.map((n) => n.id))
}
function clearSelection() {
selectedIds.value = new Set()
}
return {
nodes,
autoEnableNew,
isLoading,
searchQuery,
sortField,
sortDirection,
selectedIds,
selectedCount,
allFilteredSelected,
filteredNodes,
groups,
togglePartnerCollapsed,
groupSelectionState,
toggleGroupSelection,
fetch,
toggleSort,
setEnabled,
setSelectedEnabled,
setAllFilteredEnabled,
setGroupEnabled,
setAutoEnableNew,
toggleSelection,
toggleSelectAll,
clearSelection
}
}

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,7 +75,8 @@ function getPermissions(
canAccessWorkspaceMenu: true,
canManageSubscription: true,
canManageSubscriptionLifecycle: isOriginalOwner,
canTopUp: true
canTopUp: true,
canManagePartnerNodes: true
}
}
@@ -88,7 +93,8 @@ function getPermissions(
canAccessWorkspaceMenu: true,
canManageSubscription: false,
canManageSubscriptionLifecycle: false,
canTopUp: false
canTopUp: false,
canManagePartnerNodes: false
}
}