mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 01:38:03 +00:00
Compare commits
1 Commits
split/auto
...
split/allo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22627df289 |
@@ -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',
|
||||
|
||||
31
src/components/ui/checkbox/Checkbox.vue
Normal file
31
src/components/ui/checkbox/Checkbox.vue
Normal 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>
|
||||
@@ -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": {
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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]
|
||||
|
||||
89
src/platform/workspace/api/partnerNodesApi.ts
Normal file
89
src/platform/workspace/api/partnerNodesApi.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
134
src/platform/workspace/composables/usePartnerNodes.test.ts
Normal file
134
src/platform/workspace/composables/usePartnerNodes.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
285
src/platform/workspace/composables/usePartnerNodes.ts
Normal file
285
src/platform/workspace/composables/usePartnerNodes.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user