mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 09:18:26 +00:00
Compare commits
3 Commits
cc/partner
...
matt/be-27
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
554fd45096 | ||
|
|
6d4327e907 | ||
|
|
195580630b |
144
browser_tests/fixtures/utils/cloudSecretsMocks.ts
Normal file
144
browser_tests/fixtures/utils/cloudSecretsMocks.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import type { SettingDialog } from '@e2e/fixtures/components/SettingDialog'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
/**
|
||||
* Shared scaffolding for the cloud user-secrets (API keys) E2E, alongside the
|
||||
* sibling cloud mocks (`cloudBillingMocks`, `workspaceMocks`): the stateful
|
||||
* in-memory `/secrets` backend and the open-the-Secrets-panel helper, so the
|
||||
* spec files hold only the behavioral flow.
|
||||
*/
|
||||
|
||||
// `/api/features` is the remote-config source. Enabling user secrets is what
|
||||
// surfaces the Secrets settings panel for a signed-in user.
|
||||
export const SECRETS_BOOT_FEATURES = {
|
||||
user_secrets_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
|
||||
// TutorialCompleted suppresses the new-user template browser, whose modal
|
||||
// overlay (z-1700) would otherwise intercept clicks on the settings dialog.
|
||||
export const SECRETS_BOOT_SETTINGS = { 'Comfy.TutorialCompleted': true }
|
||||
|
||||
interface SecretRecord {
|
||||
id: string
|
||||
name: string
|
||||
provider?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
last_used_at?: string
|
||||
}
|
||||
|
||||
interface CreateCapture {
|
||||
name?: string
|
||||
provider?: string
|
||||
secret_value?: string
|
||||
}
|
||||
|
||||
interface SecretsBackend {
|
||||
/** Bodies received by POST /secrets, in order — for asserting what was sent. */
|
||||
createRequests: CreateCapture[]
|
||||
/** Current server-side store — for asserting delete actually removed a row. */
|
||||
store: SecretRecord[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful mock of the ingest `/secrets` surface. A single route handler
|
||||
* branches on path + method so registration order can never make a specific
|
||||
* path (`/secrets/providers`, `/secrets/:id`) lose to the collection glob.
|
||||
*
|
||||
* `providerIds` models entitlement: an entitled account sees runway/gemini,
|
||||
* a non-entitled account gets an empty list (the server omits them).
|
||||
*/
|
||||
export async function mockSecretsBackend(
|
||||
page: Page,
|
||||
providerIds: string[]
|
||||
): Promise<SecretsBackend> {
|
||||
const backend: SecretsBackend = { createRequests: [], store: [] }
|
||||
let idSeq = 0
|
||||
|
||||
const respondList = (route: Route) =>
|
||||
route.fulfill(jsonRoute({ data: backend.store }))
|
||||
|
||||
await page.route('**/api/secrets**', async (route) => {
|
||||
const request = route.request()
|
||||
const { pathname } = new URL(request.url())
|
||||
const method = request.method()
|
||||
|
||||
// The glob `**/api/secrets**` also matches the panel's own lazy-loaded
|
||||
// source module (`/src/platform/secrets/api/secretsApi.ts`), whose path
|
||||
// contains the `/api/secrets` substring. Fulfilling that dev-server module
|
||||
// request with JSON breaks the dynamic import and the panel never mounts.
|
||||
// Anchor to the start of the pathname so only genuine `/api/secrets…` API
|
||||
// routes are handled; everything else falls through to the real Vite server.
|
||||
if (!/^\/api\/secrets(\/|$)/.test(pathname)) {
|
||||
return route.continue()
|
||||
}
|
||||
|
||||
// GET /secrets/providers — the entitlement-gated provider allowlist.
|
||||
if (pathname.endsWith('/secrets/providers')) {
|
||||
return route.fulfill(
|
||||
jsonRoute({ data: providerIds.map((id) => ({ id })) })
|
||||
)
|
||||
}
|
||||
|
||||
// /secrets/:id — item routes (only DELETE is exercised by this flow).
|
||||
const itemMatch = pathname.match(/\/secrets\/([^/]+)$/)
|
||||
if (itemMatch) {
|
||||
const id = itemMatch[1]
|
||||
if (method === 'DELETE') {
|
||||
backend.store = backend.store.filter((s) => s.id !== id)
|
||||
return route.fulfill({ status: 204, body: '' })
|
||||
}
|
||||
return respondList(route)
|
||||
}
|
||||
|
||||
// /secrets — collection routes.
|
||||
if (method === 'POST') {
|
||||
const body = (request.postDataJSON() ?? {}) as CreateCapture
|
||||
backend.createRequests.push(body)
|
||||
idSeq += 1
|
||||
const created: SecretRecord = {
|
||||
id: `00000000-0000-4000-8000-${String(idSeq).padStart(12, '0')}`,
|
||||
name: body.name ?? '',
|
||||
provider: body.provider,
|
||||
created_at: '2026-07-08T00:00:00Z',
|
||||
updated_at: '2026-07-08T00:00:00Z'
|
||||
}
|
||||
backend.store.push(created)
|
||||
// Response echoes metadata ONLY — the schema has no secret_value field.
|
||||
return route.fulfill(jsonRoute(created))
|
||||
}
|
||||
|
||||
// GET /secrets (list).
|
||||
return respondList(route)
|
||||
})
|
||||
|
||||
return backend
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the settings dialog and land on the Secrets panel, waiting for both the
|
||||
* provider allowlist and the secret list to resolve so subsequent assertions
|
||||
* are not racing the panel's on-mount fetches. Returns the dialog root locator
|
||||
* for scoping the caller's assertions.
|
||||
*/
|
||||
export async function openSecretsPanel(settingDialog: SettingDialog) {
|
||||
const { page } = settingDialog
|
||||
await settingDialog.open()
|
||||
|
||||
const providersResolved = page.waitForResponse((r) =>
|
||||
r.url().includes('/api/secrets/providers')
|
||||
)
|
||||
const listResolved = page.waitForResponse(
|
||||
(r) =>
|
||||
/\/api\/secrets(\?|$)/.test(r.url()) && r.request().method() === 'GET'
|
||||
)
|
||||
|
||||
await settingDialog.category('Secrets').click()
|
||||
|
||||
await Promise.all([providersResolved, listResolved])
|
||||
return settingDialog.root
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { ComfyPage, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
import {
|
||||
SECRETS_BOOT_FEATURES,
|
||||
SECRETS_BOOT_SETTINGS,
|
||||
mockSecretsBackend,
|
||||
openSecretsPanel
|
||||
} from '@e2e/fixtures/utils/cloudSecretsMocks'
|
||||
|
||||
/**
|
||||
* End-to-end coverage for the user-secrets (API keys) surface in the cloud app:
|
||||
@@ -15,164 +17,28 @@ import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
*
|
||||
* Drives a raw `page` against fully-mocked endpoints (the `comfyPage` fixture
|
||||
* would reach the OSS devtools backend during setup); `mockCloudBoot` +
|
||||
* `bootCloud` boot the app signed-in, and this spec layers a stateful in-memory
|
||||
* `/secrets` backend on top so the flow is deterministic and never touches a
|
||||
* real server.
|
||||
* `bootCloud` boot the app signed-in, and `mockSecretsBackend` layers a
|
||||
* stateful in-memory `/secrets` backend on top so the flow is deterministic
|
||||
* and never touches a real server. A bare `ComfyPage` (constructed, never
|
||||
* `setup()`) supplies the shared `SettingDialog` page object without the
|
||||
* backend-touching fixture setup.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
// `/api/features` is the remote-config source. Enabling user secrets is what
|
||||
// surfaces the Secrets settings panel for a signed-in user.
|
||||
const BOOT_FEATURES = {
|
||||
user_secrets_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
|
||||
// TutorialCompleted suppresses the new-user template browser, whose modal
|
||||
// overlay (z-1700) would otherwise intercept clicks on the settings dialog.
|
||||
const BOOT_SETTINGS = { 'Comfy.TutorialCompleted': true }
|
||||
|
||||
// The plaintext key a user types in. It must be sent on create but NEVER echoed
|
||||
// back by the API or rendered anywhere in the UI.
|
||||
const RUNWAY_KEY_VALUE = 'sk-runway-do-not-echo-0xDEADBEEF'
|
||||
|
||||
interface SecretRecord {
|
||||
id: string
|
||||
name: string
|
||||
provider?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
last_used_at?: string
|
||||
}
|
||||
|
||||
interface CreateCapture {
|
||||
name?: string
|
||||
provider?: string
|
||||
secret_value?: string
|
||||
}
|
||||
|
||||
interface SecretsBackend {
|
||||
/** Bodies received by POST /secrets, in order — for asserting what was sent. */
|
||||
createRequests: CreateCapture[]
|
||||
/** Current server-side store — for asserting delete actually removed a row. */
|
||||
store: SecretRecord[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful mock of the ingest `/secrets` surface. A single route handler
|
||||
* branches on path + method so registration order can never make a specific
|
||||
* path (`/secrets/providers`, `/secrets/:id`) lose to the collection glob.
|
||||
*
|
||||
* `providerIds` models entitlement: an entitled account sees runway/gemini,
|
||||
* a non-entitled account gets an empty list (the server omits them).
|
||||
*/
|
||||
async function mockSecretsBackend(
|
||||
page: Page,
|
||||
providerIds: string[]
|
||||
): Promise<SecretsBackend> {
|
||||
const backend: SecretsBackend = { createRequests: [], store: [] }
|
||||
let idSeq = 0
|
||||
|
||||
const respondList = (route: Route) =>
|
||||
route.fulfill(jsonRoute({ data: backend.store }))
|
||||
|
||||
await page.route('**/api/secrets**', async (route) => {
|
||||
const request = route.request()
|
||||
const { pathname } = new URL(request.url())
|
||||
const method = request.method()
|
||||
|
||||
// The glob `**/api/secrets**` also matches the panel's own lazy-loaded
|
||||
// source module (`/src/platform/secrets/api/secretsApi.ts`), whose path
|
||||
// contains the `/api/secrets` substring. Fulfilling that dev-server module
|
||||
// request with JSON breaks the dynamic import and the panel never mounts.
|
||||
// Anchor to the start of the pathname so only genuine `/api/secrets…` API
|
||||
// routes are handled; everything else falls through to the real Vite server.
|
||||
if (!/^\/api\/secrets(\/|$)/.test(pathname)) {
|
||||
return route.continue()
|
||||
}
|
||||
|
||||
// GET /secrets/providers — the entitlement-gated provider allowlist.
|
||||
if (pathname.endsWith('/secrets/providers')) {
|
||||
return route.fulfill(
|
||||
jsonRoute({ data: providerIds.map((id) => ({ id })) })
|
||||
)
|
||||
}
|
||||
|
||||
// /secrets/:id — item routes (only DELETE is exercised by this flow).
|
||||
const itemMatch = pathname.match(/\/secrets\/([^/]+)$/)
|
||||
if (itemMatch) {
|
||||
const id = itemMatch[1]
|
||||
if (method === 'DELETE') {
|
||||
backend.store = backend.store.filter((s) => s.id !== id)
|
||||
return route.fulfill({ status: 204, body: '' })
|
||||
}
|
||||
return respondList(route)
|
||||
}
|
||||
|
||||
// /secrets — collection routes.
|
||||
if (method === 'POST') {
|
||||
const body = (request.postDataJSON() ?? {}) as CreateCapture
|
||||
backend.createRequests.push(body)
|
||||
idSeq += 1
|
||||
const created: SecretRecord = {
|
||||
id: `00000000-0000-4000-8000-${String(idSeq).padStart(12, '0')}`,
|
||||
name: body.name ?? '',
|
||||
provider: body.provider,
|
||||
created_at: '2026-07-08T00:00:00Z',
|
||||
updated_at: '2026-07-08T00:00:00Z'
|
||||
}
|
||||
backend.store.push(created)
|
||||
// Response echoes metadata ONLY — the schema has no secret_value field.
|
||||
return route.fulfill(jsonRoute(created))
|
||||
}
|
||||
|
||||
// GET /secrets (list).
|
||||
return respondList(route)
|
||||
})
|
||||
|
||||
return backend
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the settings dialog and land on the Secrets panel, waiting for both the
|
||||
* provider allowlist and the secret list to resolve so subsequent assertions
|
||||
* are not racing the panel's on-mount fetches.
|
||||
*/
|
||||
async function openSecretsPanel(page: Page) {
|
||||
const settingsDialog = page.getByTestId('settings-dialog')
|
||||
|
||||
await page.evaluate(() => {
|
||||
const app = window.app
|
||||
if (!app) throw new Error('window.app is not available')
|
||||
return app.extensionManager.command.execute('Comfy.ShowSettingsDialog')
|
||||
})
|
||||
await settingsDialog.waitFor({ state: 'visible' })
|
||||
|
||||
const providersResolved = page.waitForResponse((r) =>
|
||||
r.url().includes('/api/secrets/providers')
|
||||
)
|
||||
const listResolved = page.waitForResponse(
|
||||
(r) =>
|
||||
/\/api\/secrets(\?|$)/.test(r.url()) && r.request().method() === 'GET'
|
||||
)
|
||||
|
||||
await settingsDialog
|
||||
.locator('nav')
|
||||
.getByRole('button', { name: 'Secrets' })
|
||||
.click()
|
||||
|
||||
await Promise.all([providersResolved, listResolved])
|
||||
return settingsDialog
|
||||
}
|
||||
|
||||
test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
|
||||
test('an entitled account can add, list, and delete a provider key', async ({
|
||||
page
|
||||
page,
|
||||
request
|
||||
}) => {
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page, {
|
||||
features: BOOT_FEATURES,
|
||||
settings: BOOT_SETTINGS
|
||||
features: SECRETS_BOOT_FEATURES,
|
||||
settings: SECRETS_BOOT_SETTINGS
|
||||
})
|
||||
await bootCloud(page)
|
||||
const backend = await mockSecretsBackend(page, ['runway', 'gemini'])
|
||||
@@ -182,7 +48,8 @@ test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
const settingsDialog = await openSecretsPanel(page)
|
||||
const comfyPage = new ComfyPage(page, request)
|
||||
const settingsDialog = await openSecretsPanel(comfyPage.settingDialog)
|
||||
|
||||
// Empty state before anything is added.
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
|
||||
@@ -219,6 +86,22 @@ test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
|
||||
// ...but the value must never be echoed back into the list — the API
|
||||
// response carries metadata only, so nothing should render it as text.
|
||||
await expect(page.getByText(RUNWAY_KEY_VALUE)).toHaveCount(0)
|
||||
// `getByText` only sees text nodes; a value reflected into an `<input>` or
|
||||
// masked field would slip past it, so assert no field carries it either.
|
||||
// The list has already settled above, so this is a single immediate
|
||||
// assertion — a poll-until-false could mask a value that briefly echoed
|
||||
// into a field and then cleared within the polling window.
|
||||
const secretEchoedInField = await page
|
||||
.locator('input, textarea')
|
||||
.evaluateAll(
|
||||
(fields, value) =>
|
||||
fields.some(
|
||||
(field) =>
|
||||
(field as HTMLInputElement | HTMLTextAreaElement).value === value
|
||||
),
|
||||
RUNWAY_KEY_VALUE
|
||||
)
|
||||
expect(secretEchoedInField).toBe(false)
|
||||
|
||||
// --- DELETE ----------------------------------------------------------
|
||||
await settingsDialog
|
||||
@@ -238,13 +121,14 @@ test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
|
||||
})
|
||||
|
||||
test('a non-entitled account never sees the gated providers', async ({
|
||||
page
|
||||
page,
|
||||
request
|
||||
}) => {
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page, {
|
||||
features: BOOT_FEATURES,
|
||||
settings: BOOT_SETTINGS
|
||||
features: SECRETS_BOOT_FEATURES,
|
||||
settings: SECRETS_BOOT_SETTINGS
|
||||
})
|
||||
await bootCloud(page)
|
||||
// Non-entitled: the server omits runway/gemini from the allowlist.
|
||||
@@ -255,7 +139,8 @@ test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
const settingsDialog = await openSecretsPanel(page)
|
||||
const comfyPage = new ComfyPage(page, request)
|
||||
const settingsDialog = await openSecretsPanel(comfyPage.settingDialog)
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
|
||||
|
||||
// The add form opens, but its provider dropdown is empty — the gated
|
||||
|
||||
@@ -12,7 +12,6 @@ import { computed, onMounted, watch } from 'vue'
|
||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||
import config from '@/config'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { installPartnerNodeEnforcement } from '@/platform/workspace/partnerNodeAccess/installPartnerNodeEnforcement'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
@@ -22,8 +21,6 @@ import { useConflictDetection } from '@/workbench/extensions/manager/composables
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
app.extensionManager = useWorkspaceStore()
|
||||
|
||||
installPartnerNodeEnforcement()
|
||||
|
||||
const conflictDetection = useConflictDetection()
|
||||
const isLoading = computed<boolean>(() => workspaceStore.spinner)
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ const Body = defineComponent({
|
||||
setup: () => () => h('p', { 'data-testid': 'body' }, 'body content')
|
||||
})
|
||||
|
||||
const flushPromises = () =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
const ClosedNonModalDialog = defineComponent({
|
||||
name: 'ClosedNonModalDialog',
|
||||
setup: () => () =>
|
||||
@@ -361,6 +364,82 @@ describe('GlobalDialog Reka overlay scrim', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('GlobalDialog Reka focus-outside binding', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
// Reka's DismissableLayer fires focus-outside off a real focus transition
|
||||
// (blur inside the layer, then focusin on the new target), so drive the
|
||||
// mounted binding by moving focus to a fresh element outside the dialog
|
||||
// rather than dispatching a synthetic event.
|
||||
async function moveFocusToPlainElementOutside() {
|
||||
const outside = document.createElement('button')
|
||||
document.body.appendChild(outside)
|
||||
outside.focus()
|
||||
return () => outside.remove()
|
||||
}
|
||||
|
||||
it('dismisses on focus-outside by default', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'focus-default',
|
||||
title: 'Focus dismisses',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'reka', modal: false }
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
const removeOutside = await moveFocusToPlainElementOutside()
|
||||
try {
|
||||
await waitFor(() =>
|
||||
expect(store.isDialogOpen('focus-default')).toBe(false)
|
||||
)
|
||||
} finally {
|
||||
removeOutside()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not dismiss on focus-outside when dismissOnFocusOutside is false', async () => {
|
||||
// Exercises GlobalDialog's own template wiring
|
||||
// `@focus-outside="(e) => onRekaFocusOutside(e, item.dialogComponentProps)"`
|
||||
// through a mounted dialog — the direct `onRekaFocusOutside` unit test can't
|
||||
// catch a regression that drops the props argument here. The positive
|
||||
// control above proves the focus-outside path really fires, so this staying
|
||||
// open isolates the opt-out flag rather than a dead event.
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'focus-opted-out',
|
||||
title: 'Focus blocked',
|
||||
component: Body,
|
||||
dialogComponentProps: {
|
||||
renderer: 'reka',
|
||||
modal: false,
|
||||
dismissOnFocusOutside: false
|
||||
}
|
||||
})
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
const removeOutside = await moveFocusToPlainElementOutside()
|
||||
try {
|
||||
// Drain every pending microtask so a wrongful dismiss lands before we
|
||||
// assert, regardless of how many awaits deep the handler chain runs.
|
||||
await flushPromises()
|
||||
expect(store.isDialogOpen('focus-opted-out')).toBe(true)
|
||||
} finally {
|
||||
removeOutside()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldPreventRekaDismiss', () => {
|
||||
function makeEvent(target: Element | null) {
|
||||
let prevented = false
|
||||
|
||||
@@ -1566,7 +1566,6 @@
|
||||
"Workspace": "Workspace",
|
||||
"Error System": "Error System",
|
||||
"Other": "Other",
|
||||
"PartnerNodes": "Partner Nodes",
|
||||
"Secrets": "Secrets",
|
||||
"Node Library": "Node Library",
|
||||
"PointCloud": "Point Cloud"
|
||||
@@ -2855,16 +2854,6 @@
|
||||
"updatePassword": "Update Password"
|
||||
},
|
||||
"workspacePanel": {
|
||||
"partnerNodes": {
|
||||
"title": "Partner Nodes",
|
||||
"description": "Control which partner (API) nodes this workspace can discover and run.",
|
||||
"searchPlaceholder": "Search partner nodes",
|
||||
"enableAll": "Enable all",
|
||||
"disableAll": "Disable all",
|
||||
"enabledCount": "{enabled} of {total} enabled",
|
||||
"empty": "No partner nodes are available on this server.",
|
||||
"autoEnableLabel": "Auto-enable newly added partner nodes"
|
||||
},
|
||||
"invite": "Invite",
|
||||
"inviteMember": "Invite member",
|
||||
"inviteLimitReached": "You've reached the maximum of {count} members",
|
||||
@@ -4404,15 +4393,7 @@
|
||||
"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",
|
||||
"hideDisabledPartnerNodes": "Hide Disabled Partner Nodes",
|
||||
"hideDisabledPartnerNodesDescription": "Hides partner nodes disabled by workspace policy"
|
||||
},
|
||||
"partnerNodeAccess": {
|
||||
"insertBlockedTitle": "Partner node disabled",
|
||||
"insertBlockedDetail": "{node} is disabled for this workspace. A workspace owner or admin can re-enable it in Settings > Partner Nodes.",
|
||||
"runBlockedTitle": "Workflow uses disabled partner nodes",
|
||||
"runBlockedDetail": "This workflow can't run because it uses partner nodes disabled for this workspace: {nodes}. A workspace owner or admin can re-enable them in Settings > Partner Nodes."
|
||||
"hideSubgraphDescription": "Temporarily hides subgraph nodes from node library and search"
|
||||
},
|
||||
"secrets": {
|
||||
"title": "API Keys & Secrets",
|
||||
|
||||
@@ -28,7 +28,6 @@ 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]',
|
||||
@@ -193,21 +192,6 @@ export function useSettingUI(
|
||||
() => teamWorkspacesEnabled.value && isLoggedIn.value
|
||||
)
|
||||
|
||||
// PROTOTYPE (DES-484): unconditionally registered so the demo works on any
|
||||
// distribution. Production gates on shouldShowWorkspacePanel +
|
||||
// permissions.canManagePartnerNodes (owner/admin only).
|
||||
const partnerNodesPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'workspace-partner-nodes',
|
||||
label: 'PartnerNodes',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/workspace/components/dialogs/settings/PartnerNodesPanelContent.vue')
|
||||
)
|
||||
}
|
||||
|
||||
const secretsPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'secrets',
|
||||
@@ -261,7 +245,6 @@ export function useSettingUI(
|
||||
aboutPanel,
|
||||
creditsPanel,
|
||||
userPanel,
|
||||
partnerNodesPanel,
|
||||
...(shouldShowWorkspacePanel.value ? [workspacePanel] : []),
|
||||
keybindingPanel,
|
||||
extensionPanel,
|
||||
@@ -313,7 +296,6 @@ export function useSettingUI(
|
||||
label: 'Workspace',
|
||||
children: [
|
||||
...(shouldShowWorkspacePanel.value ? [workspacePanel.node] : []),
|
||||
partnerNodesPanel.node,
|
||||
...(isLoggedIn.value &&
|
||||
!(isCloud && window.__CONFIG__?.subscription_required)
|
||||
? [creditsPanel.node]
|
||||
@@ -360,7 +342,6 @@ export function useSettingUI(
|
||||
label: 'Account',
|
||||
children: [
|
||||
userPanel.node,
|
||||
partnerNodesPanel.node,
|
||||
...(shouldShowLegacyPlanCreditsPanel.value && subscriptionPanel
|
||||
? [subscriptionPanel.node]
|
||||
: []),
|
||||
|
||||
@@ -87,4 +87,3 @@ export type SettingPanelType =
|
||||
| 'subscription'
|
||||
| 'user'
|
||||
| 'workspace'
|
||||
| 'workspace-partner-nodes'
|
||||
|
||||
@@ -3,10 +3,6 @@ import { computed, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import { i18n, st } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import {
|
||||
normalizeProviderKey,
|
||||
usePartnerNodeAccessStore
|
||||
} from '@/platform/workspace/partnerNodeAccess/partnerNodeAccessStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { generateCategoryId, getCategoryIcon } from '@/utils/categoryUtil'
|
||||
@@ -31,39 +27,6 @@ interface EnhancedTemplate extends TemplateInfo {
|
||||
searchableText?: string
|
||||
}
|
||||
|
||||
/** Normalized provider keys declared by a template's logo overlay metadata. */
|
||||
function templateProviderKeys(template: EnhancedTemplate): string[] {
|
||||
return (template.logos ?? [])
|
||||
.flatMap((logo) =>
|
||||
Array.isArray(logo.provider) ? logo.provider : [logo.provider]
|
||||
)
|
||||
.map(normalizeProviderKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* PROTOTYPE (DES-484): whether a template must be hidden because of the
|
||||
* workspace partner-node policy. The template index carries no node-type
|
||||
* list, so provider identity from logo metadata is the only per-partner
|
||||
* signal; `isPartnerNode` (openSource === false) marks partner templates
|
||||
* that may lack that metadata.
|
||||
*
|
||||
* @param template - The template under evaluation; `template.isPartnerNode`
|
||||
* is true when the template runs on partner/API nodes.
|
||||
* @param providerKeys - Normalized provider keys from the template's logos
|
||||
* (may be empty when metadata is missing).
|
||||
* @param disabledPartners - Normalized keys of partners whose nodes are all
|
||||
* disabled in this workspace.
|
||||
*/
|
||||
function isTemplateBlockedByPartnerPolicy(
|
||||
template: EnhancedTemplate,
|
||||
providerKeys: string[],
|
||||
disabledPartners: Set<string>
|
||||
): boolean {
|
||||
if (!template.isPartnerNode) return false
|
||||
if (!providerKeys.length) return true
|
||||
return providerKeys.some((key) => disabledPartners.has(key))
|
||||
}
|
||||
|
||||
export const useWorkflowTemplatesStore = defineStore(
|
||||
'workflowTemplates',
|
||||
() => {
|
||||
@@ -286,16 +249,7 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
(template) => !template.requiresCustomNodes?.length
|
||||
)
|
||||
|
||||
const disabledPartners = usePartnerNodeAccessStore().fullyDisabledPartners
|
||||
if (!disabledPartners.size) return filteredTemplates
|
||||
return filteredTemplates.filter(
|
||||
(template) =>
|
||||
!isTemplateBlockedByPartnerPolicy(
|
||||
template,
|
||||
templateProviderKeys(template),
|
||||
disabledPartners
|
||||
)
|
||||
)
|
||||
return filteredTemplates
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
<template>
|
||||
<TabPanel value="PartnerNodes" class="h-full">
|
||||
<div class="flex h-full flex-col">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold">
|
||||
{{ $t('workspacePanel.partnerNodes.title') }}
|
||||
</h2>
|
||||
<div class="mt-1 flex items-center justify-between gap-4">
|
||||
<p class="my-0 text-sm text-muted">
|
||||
{{ $t('workspacePanel.partnerNodes.description') }}
|
||||
</p>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<Button variant="textonly" size="sm" @click="setAllFiltered(true)">
|
||||
{{ $t('workspacePanel.partnerNodes.enableAll') }}
|
||||
</Button>
|
||||
<Button variant="textonly" size="sm" @click="setAllFiltered(false)">
|
||||
{{ $t('workspacePanel.partnerNodes.disableAll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider class="my-4" />
|
||||
|
||||
<SearchInput
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('workspacePanel.partnerNodes.searchPlaceholder')"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="!accessStore.partnerNodes.length"
|
||||
class="py-8 text-center text-sm text-muted"
|
||||
>
|
||||
{{ $t('workspacePanel.partnerNodes.empty') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="min-h-0 flex-1 overflow-y-auto">
|
||||
<section
|
||||
v-for="group in groupedNodes"
|
||||
:key="group.partner"
|
||||
class="mb-6"
|
||||
>
|
||||
<h3 class="mb-1 text-sm font-semibold">
|
||||
{{ group.partner }}
|
||||
<span class="ml-2 font-normal text-muted">
|
||||
{{
|
||||
$t('workspacePanel.partnerNodes.enabledCount', {
|
||||
enabled: group.enabledCount,
|
||||
total: group.nodes.length
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</h3>
|
||||
<ul class="m-0 list-none p-0">
|
||||
<li
|
||||
v-for="node in group.nodes"
|
||||
:key="node.name"
|
||||
class="flex items-center justify-between py-1.5"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'text-sm',
|
||||
!accessStore.isNodeTypeEnabled(node.name) && 'opacity-40'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ node.displayName }}
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
:model-value="accessStore.isNodeTypeEnabled(node.name)"
|
||||
@update:model-value="
|
||||
accessStore.setEnabled([node.name], $event)
|
||||
"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-2 pt-2">
|
||||
<ToggleSwitch
|
||||
:model-value="accessStore.autoEnableNew"
|
||||
@update:model-value="accessStore.setAutoEnableNew($event)"
|
||||
/>
|
||||
<span
|
||||
:class="cn('text-sm', !accessStore.autoEnableNew && 'text-muted')"
|
||||
>
|
||||
{{ $t('workspacePanel.partnerNodes.autoEnableLabel') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import Divider from 'primevue/divider'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import type { PartnerNodeEntry } from '@/platform/workspace/partnerNodeAccess/partnerNodeAccessStore'
|
||||
import { usePartnerNodeAccessStore } from '@/platform/workspace/partnerNodeAccess/partnerNodeAccessStore'
|
||||
|
||||
interface PartnerGroup {
|
||||
partner: string
|
||||
nodes: PartnerNodeEntry[]
|
||||
enabledCount: number
|
||||
}
|
||||
|
||||
const accessStore = usePartnerNodeAccessStore()
|
||||
const searchQuery = ref('')
|
||||
|
||||
const filteredNodes = computed<PartnerNodeEntry[]>(() => {
|
||||
const query = searchQuery.value.trim().toLowerCase()
|
||||
if (!query) return accessStore.partnerNodes
|
||||
return accessStore.partnerNodes.filter(
|
||||
(node) =>
|
||||
node.displayName.toLowerCase().includes(query) ||
|
||||
node.name.toLowerCase().includes(query) ||
|
||||
node.partner.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
const groupedNodes = computed<PartnerGroup[]>(() => {
|
||||
const byPartner = new Map<string, PartnerNodeEntry[]>()
|
||||
for (const node of filteredNodes.value) {
|
||||
const nodes = byPartner.get(node.partner) ?? []
|
||||
nodes.push(node)
|
||||
byPartner.set(node.partner, nodes)
|
||||
}
|
||||
return [...byPartner.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([partner, nodes]) => ({
|
||||
partner,
|
||||
nodes: nodes.toSorted((a, b) =>
|
||||
a.displayName.localeCompare(b.displayName)
|
||||
),
|
||||
enabledCount: nodes.filter((node) =>
|
||||
accessStore.isNodeTypeEnabled(node.name)
|
||||
).length
|
||||
}))
|
||||
})
|
||||
|
||||
function setAllFiltered(enabled: boolean) {
|
||||
accessStore.setEnabled(
|
||||
filteredNodes.value.map((node) => node.name),
|
||||
enabled
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -1,38 +0,0 @@
|
||||
import { watchEffect } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
import { usePartnerNodeAccessStore } from './partnerNodeAccessStore'
|
||||
|
||||
/**
|
||||
* PROTOTYPE (DES-484): hides workspace-disabled partner nodes from every
|
||||
* discovery surface. The node-def filter covers search (v1/v2) and both
|
||||
* node-library sidebars via `visibleNodeDefs`; the `skip_list` sync covers
|
||||
* the litegraph right-click Add Node menu (same pattern as the dev_only
|
||||
* sync in nodeDefStore). Creation paths that bypass discovery (paste,
|
||||
* workflow load) are enforced at run time instead.
|
||||
*/
|
||||
export function installPartnerNodeEnforcement() {
|
||||
const accessStore = usePartnerNodeAccessStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
nodeDefStore.registerNodeDefFilter({
|
||||
id: 'core.partnerNodeAccess',
|
||||
name: t('nodeFilters.hideDisabledPartnerNodes'),
|
||||
description: t('nodeFilters.hideDisabledPartnerNodesDescription'),
|
||||
predicate: (nodeDef) => !accessStore.disabledNodeTypes.has(nodeDef.name)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const disabled = accessStore.disabledNodeTypes
|
||||
for (const [typeName, nodeType] of Object.entries(
|
||||
LiteGraph.registered_node_types
|
||||
)) {
|
||||
if (nodeType.nodeData?.api_node) {
|
||||
nodeType.skip_list = disabled.has(typeName)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { getProviderName } from '@/utils/categoryUtil'
|
||||
|
||||
export interface PartnerNodeEntry {
|
||||
name: string
|
||||
displayName: string
|
||||
partner: string
|
||||
}
|
||||
|
||||
interface PersistedAccessState {
|
||||
overrides: Record<string, boolean>
|
||||
autoEnableNew: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* PROTOTYPE (DES-484): persisted per-browser via localStorage. The
|
||||
* production version reads/writes `/api/workspace/partner-nodes` and is
|
||||
* enforced server-side at `/prompt`; this store's public shape mirrors
|
||||
* that contract so consumers survive the swap.
|
||||
*/
|
||||
const STORAGE_KEY = 'Comfy.Prototype.PartnerNodeAccess'
|
||||
|
||||
export function normalizeProviderKey(provider: string): string {
|
||||
return provider.toLowerCase().replaceAll(/\s+/g, '-')
|
||||
}
|
||||
|
||||
export const usePartnerNodeAccessStore = defineStore(
|
||||
'partnerNodeAccess',
|
||||
() => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
const persisted = useLocalStorage<PersistedAccessState>(STORAGE_KEY, {
|
||||
overrides: {},
|
||||
autoEnableNew: true
|
||||
})
|
||||
|
||||
const partnerNodes = computed<PartnerNodeEntry[]>(() =>
|
||||
Object.values(nodeDefStore.nodeDefsByName)
|
||||
.filter((def) => def.api_node)
|
||||
.map((def) => ({
|
||||
name: def.name,
|
||||
displayName: def.display_name,
|
||||
partner: getProviderName(def.category)
|
||||
}))
|
||||
)
|
||||
|
||||
const autoEnableNew = computed(() => persisted.value.autoEnableNew)
|
||||
|
||||
function isNodeTypeEnabled(name: string): boolean {
|
||||
return persisted.value.overrides[name] ?? persisted.value.autoEnableNew
|
||||
}
|
||||
|
||||
const disabledNodeTypes = computed<Set<string>>(
|
||||
() =>
|
||||
new Set(
|
||||
partnerNodes.value
|
||||
.filter((node) => !isNodeTypeEnabled(node.name))
|
||||
.map((node) => node.name)
|
||||
)
|
||||
)
|
||||
|
||||
/** Partners whose nodes are all disabled, as normalized provider keys. */
|
||||
const fullyDisabledPartners = computed<Set<string>>(() => {
|
||||
const partnerHasEnabledNode = new Map<string, boolean>()
|
||||
for (const node of partnerNodes.value) {
|
||||
const key = normalizeProviderKey(node.partner)
|
||||
const anyEnabled = partnerHasEnabledNode.get(key) ?? false
|
||||
partnerHasEnabledNode.set(
|
||||
key,
|
||||
anyEnabled || isNodeTypeEnabled(node.name)
|
||||
)
|
||||
}
|
||||
return new Set(
|
||||
[...partnerHasEnabledNode]
|
||||
.filter(([, anyEnabled]) => !anyEnabled)
|
||||
.map(([key]) => key)
|
||||
)
|
||||
})
|
||||
|
||||
function setEnabled(names: string[], enabled: boolean) {
|
||||
const overrides = { ...persisted.value.overrides }
|
||||
for (const name of names) {
|
||||
overrides[name] = enabled
|
||||
}
|
||||
persisted.value = { ...persisted.value, overrides }
|
||||
}
|
||||
|
||||
function setAutoEnableNew(enabled: boolean) {
|
||||
persisted.value = { ...persisted.value, autoEnableNew: enabled }
|
||||
}
|
||||
|
||||
return {
|
||||
partnerNodes,
|
||||
autoEnableNew,
|
||||
disabledNodeTypes,
|
||||
fullyDisabledPartners,
|
||||
isNodeTypeEnabled,
|
||||
setEnabled,
|
||||
setAutoEnableNew
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -31,7 +31,6 @@ import { installNodeAddedTelemetry } from '@/platform/telemetry/nodeAdded/instal
|
||||
import { groupMissingNodesByPack } from '@/platform/telemetry/utils/groupMissingNodesByPack'
|
||||
import type { WorkflowOpenSource } from '@/platform/telemetry/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { usePartnerNodeAccessStore } from '@/platform/workspace/partnerNodeAccess/partnerNodeAccessStore'
|
||||
import { updatePendingWarnings } from '@/platform/workflow/core/utils/pendingWarnings'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -1611,43 +1610,11 @@ export class ComfyApp {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PROTOTYPE (DES-484): display names of workspace-disabled partner nodes
|
||||
* present in the current graph (including subgraphs). Production replaces
|
||||
* this client check with server-side `/prompt` rejection.
|
||||
*/
|
||||
collectDisabledPartnerNodes(): string[] {
|
||||
const disabled = usePartnerNodeAccessStore().disabledNodeTypes
|
||||
if (!disabled.size) return []
|
||||
const blocked = new Set<string>()
|
||||
forEachNode(this.rootGraph, (node) => {
|
||||
const nodeData = node.constructor?.nodeData
|
||||
const typeName = nodeData?.name ?? node.type
|
||||
if (typeName && disabled.has(typeName)) {
|
||||
blocked.add(String(nodeData?.display_name ?? typeName))
|
||||
}
|
||||
})
|
||||
return [...blocked]
|
||||
}
|
||||
|
||||
async queuePrompt(
|
||||
number: number,
|
||||
batchCount: number = 1,
|
||||
queueNodeIds?: NodeExecutionId[]
|
||||
): Promise<boolean> {
|
||||
const disabledPartnerNodes = this.collectDisabledPartnerNodes()
|
||||
if (disabledPartnerNodes.length) {
|
||||
useDialogService().showErrorDialog(
|
||||
new Error(
|
||||
t('partnerNodeAccess.runBlockedDetail', {
|
||||
nodes: disabledPartnerNodes.join(', ')
|
||||
})
|
||||
),
|
||||
{ title: t('partnerNodeAccess.runBlockedTitle') }
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const requestId = this.nextQueueRequestId++
|
||||
this.queueItems.push({ number, batchCount, queueNodeIds, requestId })
|
||||
api.dispatchCustomEvent('promptQueueing', {
|
||||
|
||||
@@ -38,7 +38,6 @@ import type {
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { usePartnerNodeAccessStore } from '@/platform/workspace/partnerNodeAccess/partnerNodeAccessStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { createPromotedMultilineWidget } from '@/renderer/extensions/vueNodes/widgets/utils/multilineTextarea'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
@@ -895,18 +894,6 @@ export const useLitegraphService = () => {
|
||||
options: CreateNodeOptions = {},
|
||||
addOptions?: GraphAddOptions
|
||||
): LGraphNode | null {
|
||||
if (usePartnerNodeAccessStore().disabledNodeTypes.has(nodeDef.name)) {
|
||||
useToastStore().add({
|
||||
severity: 'warn',
|
||||
summary: t('partnerNodeAccess.insertBlockedTitle'),
|
||||
detail: t('partnerNodeAccess.insertBlockedDetail', {
|
||||
node: nodeDef.display_name ?? nodeDef.name
|
||||
}),
|
||||
life: 5000
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
options.pos ??= getCanvasCenter()
|
||||
|
||||
if (isBlueprintType(nodeDef.name)) {
|
||||
|
||||
Reference in New Issue
Block a user