Compare commits

...

5 Commits

Author SHA1 Message Date
kishore
6898665823 feat: polish OAuth consent UI and align with workspace switcher design
- Workspace picker is now an inline radio list (matches the cloud
  workspace switcher) instead of a dropdown — uses WorkspaceProfilePic
  avatars so OAuth consent feels native to the cloud app.
- Cap list at max-h-72 with scrollbar-custom so 10+ workspaces stay
  discoverable.
- Resume cloud calls are now relative (same-origin via Vite proxy or
  prod reverse proxy) — fixes the cross-origin cookie loss that would
  bounce the consent challenge back to login.
- Map 400 / 403 / 404 cloud errors to user-facing messages
  (expired / scope_broadening / feature_unavailable).
- Surface registered redirect_uri and RFC 7591 application_type so
  users can verify the loopback destination before granting.
2026-05-11 19:04:26 -07:00
kishore
607bd8a309 fix: proxy registry API in local cloud dev
Route /customers and /releases to the Comfy registry host when the backend is loopback ingest; those paths are not implemented on ingest.

Stub /api/i18n for cloud dev so bootstrap does not hard-fail against OSS-only routes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 00:31:19 -07:00
kishore
101f054395 fix: disable Mixpanel in local cloud dev
Avoid sending local OAuth browser-test telemetry to the cloud Mixpanel proxy when the cloud frontend is pointed at a loopback backend.
2026-05-08 00:27:26 -07:00
kishore
cb042bee24 fix: use local API base in cloud dev
Keep local cloud development on the Vite proxy path so user-scoped customer requests do not leak to staging.
2026-05-08 00:02:34 -07:00
kishore
e3d2ae3cef feat: add OAuth frontend consent flow
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 23:33:12 -07:00
25 changed files with 1410 additions and 35 deletions

1
global.d.ts vendored
View File

@@ -5,6 +5,7 @@ declare const __SENTRY_DSN__: string
declare const __ALGOLIA_APP_ID__: string
declare const __ALGOLIA_API_KEY__: string
declare const __USE_PROD_CONFIG__: boolean
declare const __DEV_SERVER_COMFYUI_URL__: string
interface ImpactQueueFunction {
(...args: unknown[]): void

View File

@@ -11,6 +11,7 @@ declare global {
const __ALGOLIA_APP_ID__: string
const __ALGOLIA_API_KEY__: string
const __USE_PROD_CONFIG__: boolean
const __DEV_SERVER_COMFYUI_URL__: string
const __DISTRIBUTION__: 'desktop' | 'localhost' | 'cloud'
const __IS_NIGHTLY__: boolean
}
@@ -22,6 +23,7 @@ type GlobalWithDefines = typeof globalThis & {
__ALGOLIA_APP_ID__: string
__ALGOLIA_API_KEY__: string
__USE_PROD_CONFIG__: boolean
__DEV_SERVER_COMFYUI_URL__: string
__DISTRIBUTION__: 'desktop' | 'localhost' | 'cloud'
__IS_NIGHTLY__: boolean
window?: Record<string, unknown>
@@ -37,6 +39,7 @@ globalWithDefines.__SENTRY_DSN__ = ''
globalWithDefines.__ALGOLIA_APP_ID__ = ''
globalWithDefines.__ALGOLIA_API_KEY__ = ''
globalWithDefines.__USE_PROD_CONFIG__ = false
globalWithDefines.__DEV_SERVER_COMFYUI_URL__ = ''
globalWithDefines.__DISTRIBUTION__ = 'localhost'
globalWithDefines.__IS_NIGHTLY__ = false

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { getComfyApiBaseUrlForEnvironment } from '@/config/comfyApi'
describe('comfy api config', () => {
it('uses same-origin API calls for cloud local development', () => {
expect(
getComfyApiBaseUrlForEnvironment({
isCloudDistribution: true,
isDev: true,
devServerComfyUIUrl: 'http://127.0.0.1:8188',
useProdConfig: false
})
).toBe('')
})
it('keeps staging API for non-local staging builds', () => {
expect(
getComfyApiBaseUrlForEnvironment({
isCloudDistribution: true,
isDev: false,
useProdConfig: false
})
).toBe('https://stagingapi.comfy.org')
})
})

View File

@@ -10,34 +10,68 @@ const STAGING_API_BASE_URL = 'https://stagingapi.comfy.org'
const PROD_PLATFORM_BASE_URL = 'https://platform.comfy.org'
const STAGING_PLATFORM_BASE_URL = 'https://stagingplatform.comfy.org'
const BUILD_TIME_API_BASE_URL = __USE_PROD_CONFIG__
? PROD_API_BASE_URL
: STAGING_API_BASE_URL
type ComfyApiEnvironment = {
isCloudDistribution: boolean
isDev: boolean
devServerComfyUIUrl?: string
useProdConfig: boolean
}
const BUILD_TIME_PLATFORM_BASE_URL = __USE_PROD_CONFIG__
? PROD_PLATFORM_BASE_URL
: STAGING_PLATFORM_BASE_URL
const localOriginPattern =
/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/|$)/
export function getComfyApiBaseUrl(): string {
if (!isCloud) {
return BUILD_TIME_API_BASE_URL
function buildTimeApiBaseUrl(useProdConfig: boolean): string {
return useProdConfig ? PROD_API_BASE_URL : STAGING_API_BASE_URL
}
function buildTimePlatformBaseUrl(useProdConfig: boolean): string {
return useProdConfig ? PROD_PLATFORM_BASE_URL : STAGING_PLATFORM_BASE_URL
}
function isLocalDevServer(url?: string): boolean {
return url ? localOriginPattern.test(url) : false
}
export function getComfyApiBaseUrlForEnvironment({
isCloudDistribution,
isDev,
devServerComfyUIUrl,
useProdConfig
}: ComfyApiEnvironment): string {
const buildTimeApiBaseUrlValue = buildTimeApiBaseUrl(useProdConfig)
if (!isCloudDistribution) {
return buildTimeApiBaseUrlValue
}
if (isDev && isLocalDevServer(devServerComfyUIUrl)) {
return ''
}
return configValueOrDefault(
remoteConfig.value,
'comfy_api_base_url',
BUILD_TIME_API_BASE_URL
buildTimeApiBaseUrlValue
)
}
export function getComfyApiBaseUrl(): string {
return getComfyApiBaseUrlForEnvironment({
isCloudDistribution: isCloud,
isDev: import.meta.env.DEV,
devServerComfyUIUrl: __DEV_SERVER_COMFYUI_URL__,
useProdConfig: __USE_PROD_CONFIG__
})
}
export function getComfyPlatformBaseUrl(): string {
const buildTimePlatformBaseUrlValue =
buildTimePlatformBaseUrl(__USE_PROD_CONFIG__)
if (!isCloud) {
return BUILD_TIME_PLATFORM_BASE_URL
return buildTimePlatformBaseUrlValue
}
return configValueOrDefault(
remoteConfig.value,
'comfy_platform_base_url',
BUILD_TIME_PLATFORM_BASE_URL
buildTimePlatformBaseUrlValue
)
}

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import {
getFirebaseConfigForEnvironment,
getFirebaseAuthEmulatorUrl
} from '@/config/firebase'
describe('firebase config', () => {
it('uses the explicit local project id when the auth emulator is enabled', () => {
const config = getFirebaseConfigForEnvironment({
isCloudBuild: false,
useProdConfig: false,
authEmulatorHost: '127.0.0.1:9099',
localProjectId: 'demo-cloud'
})
expect(config.projectId).toBe('demo-cloud')
expect(config.authDomain).toBe('demo-cloud.firebaseapp.com')
})
it('fails fast when the auth emulator is enabled without a local project id', () => {
expect(() =>
getFirebaseConfigForEnvironment({
isCloudBuild: false,
useProdConfig: false,
authEmulatorHost: '127.0.0.1:9099'
})
).toThrow('VITE_FIREBASE_PROJECT_ID is required')
})
it('does not connect to the emulator without the explicit host flag', () => {
expect(getFirebaseAuthEmulatorUrl(undefined)).toBeNull()
expect(getFirebaseAuthEmulatorUrl('127.0.0.1:9099')).toBe(
'http://127.0.0.1:9099'
)
})
})

View File

@@ -25,7 +25,54 @@ const PROD_CONFIG: FirebaseOptions = {
measurementId: 'G-3ZBD3MBTG4'
}
const BUILD_TIME_CONFIG = __USE_PROD_CONFIG__ ? PROD_CONFIG : DEV_CONFIG
type FirebaseEnvironment = {
isCloudBuild: boolean
useProdConfig: boolean
authEmulatorHost?: string
localProjectId?: string
}
function buildLocalEmulatorConfig(
buildTimeConfig: FirebaseOptions,
localProjectId: string | undefined
): FirebaseOptions {
if (!localProjectId) {
throw new Error(
'VITE_FIREBASE_PROJECT_ID is required when VITE_FIREBASE_AUTH_EMULATOR_HOST is set'
)
}
return {
...buildTimeConfig,
projectId: localProjectId,
authDomain: `${localProjectId}.firebaseapp.com`
}
}
export function getFirebaseAuthEmulatorUrl(
host: string | undefined
): string | null {
return host ? `http://${host}` : null
}
export function getFirebaseConfigForEnvironment({
isCloudBuild,
useProdConfig,
authEmulatorHost,
localProjectId
}: FirebaseEnvironment): FirebaseOptions {
const buildTimeConfig = useProdConfig ? PROD_CONFIG : DEV_CONFIG
if (authEmulatorHost) {
return buildLocalEmulatorConfig(buildTimeConfig, localProjectId)
}
if (!isCloudBuild) {
return buildTimeConfig
}
const runtimeConfig = remoteConfig.value.firebase_config
return runtimeConfig ?? buildTimeConfig
}
/**
* Returns the Firebase configuration for the current environment.
@@ -33,10 +80,10 @@ const BUILD_TIME_CONFIG = __USE_PROD_CONFIG__ ? PROD_CONFIG : DEV_CONFIG
* - OSS / localhost builds fall back to the build-time config determined by __USE_PROD_CONFIG__
*/
export function getFirebaseConfig(): FirebaseOptions {
if (!isCloud) {
return BUILD_TIME_CONFIG
}
const runtimeConfig = remoteConfig.value.firebase_config
return runtimeConfig ?? BUILD_TIME_CONFIG
return getFirebaseConfigForEnvironment({
isCloudBuild: isCloud,
useProdConfig: __USE_PROD_CONFIG__,
authEmulatorHost: import.meta.env.VITE_FIREBASE_AUTH_EMULATOR_HOST,
localProjectId: import.meta.env.VITE_FIREBASE_PROJECT_ID
})
}

View File

@@ -2130,6 +2130,43 @@
"slots": "Node Slots Error",
"widgets": "Node Widgets Error"
},
"oauth": {
"consent": {
"allow": "Continue",
"deny": "Cancel",
"genericError": "OAuth request failed. Please restart from the client app.",
"loading": "Loading authorization request…",
"missingRequest": "This authorization request is missing. Please restart from the client app.",
"noWorkspaces": "No eligible workspaces are available for this request.",
"title": "{client} wants access",
"subtitle": "Sign in to {resource} to continue",
"resourceFallback": "this app",
"workspaceLabel": "Workspace",
"permissionsHeader": "Permissions",
"workspaceHelp": "Permissions apply to this workspace only.",
"learnMore": "Learn more",
"redirectNotice": "You'll be redirected to",
"appTypeNative": "Native app",
"appTypeWeb": "Web app",
"errorExpired": "This consent request has expired or has already been used. Please restart from the client app.",
"errorScopeBroadening": "The previously approved permissions don't cover this request. You'll need to re-authorize with the new permissions.",
"errorUnavailable": "This feature isn't available right now. Please contact support if the problem persists."
},
"scopes": {
"mcp:tools:read": {
"label": "View available workflow tools"
},
"mcp:tools:call": {
"label": "Run workflows on your behalf"
}
},
"workspace": {
"personal": "Personal",
"team": "Team",
"owner": "Owner",
"member": "Member"
}
},
"auth": {
"apiKey": {
"title": "API Key",

View File

@@ -2,6 +2,7 @@ import { definePreset } from '@primevue/themes'
import Aura from '@primevue/themes/aura'
import * as Sentry from '@sentry/vue'
import { initializeApp } from 'firebase/app'
import { connectAuthEmulator, getAuth } from 'firebase/auth'
import { createPinia } from 'pinia'
import 'primeicons/primeicons.css'
import PrimeVue from 'primevue/config'
@@ -11,7 +12,10 @@ import Tooltip from 'primevue/tooltip'
import { createApp } from 'vue'
import { VueFire, VueFireAuth } from 'vuefire'
import { getFirebaseConfig } from '@/config/firebase'
import {
getFirebaseAuthEmulatorUrl,
getFirebaseConfig
} from '@/config/firebase'
import {
configValueOrDefault,
remoteConfig
@@ -48,6 +52,14 @@ const ComfyUIPreset = definePreset(Aura, {
})
const firebaseApp = initializeApp(getFirebaseConfig())
const firebaseAuthEmulatorUrl = getFirebaseAuthEmulatorUrl(
import.meta.env.VITE_FIREBASE_AUTH_EMULATOR_HOST
)
if (firebaseAuthEmulatorUrl) {
connectAuthEmulator(getAuth(firebaseApp), firebaseAuthEmulatorUrl, {
disableWarnings: true
})
}
const app = createApp(App)
const pinia = createPinia()

View File

@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockGetIdToken = vi.fn()
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
teamWorkspacesEnabled: true
}
})
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
getIdToken: mockGetIdToken,
getAuthHeader: vi.fn()
})
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (path: string) => `/api${path}`
}
}))
describe('useSessionCookie', () => {
beforeEach(() => {
vi.resetModules()
vi.restoreAllMocks()
mockGetIdToken.mockReset()
globalThis.fetch = vi.fn()
})
it('createSessionOrThrow posts the Firebase token and awaits success', async () => {
mockGetIdToken.mockResolvedValue('firebase-id-token')
vi.mocked(globalThis.fetch).mockResolvedValue(
new Response(null, { status: 204 })
)
const { useSessionCookie } =
await import('@/platform/auth/session/useSessionCookie')
await useSessionCookie().createSessionOrThrow()
expect(globalThis.fetch).toHaveBeenCalledWith('/api/auth/session', {
method: 'POST',
credentials: 'include',
headers: {
Authorization: 'Bearer firebase-id-token',
'Content-Type': 'application/json'
}
})
})
it('createSessionOrThrow fails fast without a Firebase token', async () => {
mockGetIdToken.mockResolvedValue(null)
const { useSessionCookie } =
await import('@/platform/auth/session/useSessionCookie')
await expect(useSessionCookie().createSessionOrThrow()).rejects.toThrow(
'No Firebase token available for session creation'
)
expect(globalThis.fetch).not.toHaveBeenCalled()
})
it('createSessionOrThrow fails fast on non-success responses', async () => {
mockGetIdToken.mockResolvedValue('firebase-id-token')
vi.mocked(globalThis.fetch).mockResolvedValue(
new Response(JSON.stringify({ message: 'session denied' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
})
)
const { useSessionCookie } =
await import('@/platform/auth/session/useSessionCookie')
await expect(useSessionCookie().createSessionOrThrow()).rejects.toThrow(
'session denied'
)
})
})

View File

@@ -1,4 +1,5 @@
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { clearOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { useAuthStore } from '@/stores/authStore'
@@ -8,6 +9,35 @@ import { useAuthStore } from '@/stores/authStore'
* Creates and deletes session cookies on the ComfyUI server.
*/
export const useSessionCookie = () => {
const createSessionWithHeader = async (
authHeader: Record<string, string>
): Promise<Response> => {
return await fetch(api.apiURL('/auth/session'), {
method: 'POST',
credentials: 'include',
headers: {
...authHeader,
'Content-Type': 'application/json'
}
})
}
const readSessionError = async (response: Response): Promise<string> => {
const errorData = await response.json().catch(() => ({}))
return errorData.message || response.statusText
}
const getFirebaseSessionHeaderOrThrow = async (): Promise<
Record<string, string>
> => {
const firebaseToken = await useAuthStore().getIdToken()
if (!firebaseToken) {
throw new Error('No Firebase token available for session creation')
}
return { Authorization: `Bearer ${firebaseToken}` }
}
/**
* Creates or refreshes the session cookie.
* Called after login and on token refresh.
@@ -47,20 +77,12 @@ export const useSessionCookie = () => {
authHeader = header
}
const response = await fetch(api.apiURL('/auth/session'), {
method: 'POST',
credentials: 'include',
headers: {
...authHeader,
'Content-Type': 'application/json'
}
})
const response = await createSessionWithHeader(authHeader)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
console.warn(
'Failed to create session cookie:',
errorData.message || response.statusText
await readSessionError(response)
)
}
} catch (error) {
@@ -68,11 +90,22 @@ export const useSessionCookie = () => {
}
}
const createSessionOrThrow = async (): Promise<void> => {
if (!isCloud) return
const authHeader = await getFirebaseSessionHeaderOrThrow()
const response = await createSessionWithHeader(authHeader)
if (!response.ok) {
throw new Error(await readSessionError(response))
}
}
/**
* Deletes the session cookie.
* Called on logout.
*/
const deleteSession = async (): Promise<void> => {
clearOAuthRequestId()
if (!isCloud) return
try {
@@ -95,6 +128,7 @@ export const useSessionCookie = () => {
return {
createSession,
createSessionOrThrow,
deleteSession
}
}

View File

@@ -0,0 +1,117 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import OAuthConsentView from '@/platform/cloud/oauth/OAuthConsentView.vue'
import type { OAuthConsentChallenge } from '@/platform/cloud/oauth/oauthApi'
const baseChallenge: OAuthConsentChallenge = {
oauth_request_id: '550e8400-e29b-41d4-a716-446655440000',
csrf_token: 'preview-csrf-token',
client_display_name: 'Cursor',
resource_display_name: 'Comfy Cloud MCP',
redirect_uri: 'http://127.0.0.1:50632/cb',
client_application_type: 'native',
scopes: ['mcp:tools:read', 'mcp:tools:call'],
workspaces: [
{
id: 'personal-workspace',
name: 'Personal Workspace',
type: 'personal',
role: 'owner'
},
{
id: 'team-workspace',
name: 'Comfy Team',
type: 'team',
role: 'member'
}
]
}
const meta: Meta<typeof OAuthConsentView> = {
title: 'Cloud/OAuth/Consent',
component: OAuthConsentView
}
export default meta
type Story = StoryObj<typeof meta>
const noopSubmit = async () => {}
export const TwoWorkspaces: Story = {
args: { initialChallenge: baseChallenge, submitDecision: noopSubmit }
}
export const SingleWorkspace: Story = {
args: {
initialChallenge: {
...baseChallenge,
workspaces: [baseChallenge.workspaces[0]]
},
submitDecision: noopSubmit
}
}
export const ManyWorkspaces: Story = {
args: {
initialChallenge: {
...baseChallenge,
workspaces: [
baseChallenge.workspaces[0],
baseChallenge.workspaces[1],
{
id: 'design-team',
name: 'Design Studio',
type: 'team',
role: 'owner'
},
{
id: 'agency-team',
name: 'Agency Workspace',
type: 'team',
role: 'member'
}
]
},
submitDecision: noopSubmit
}
}
export const UnknownScope: Story = {
args: {
initialChallenge: {
...baseChallenge,
scopes: ['mcp:tools:read', 'mcp:tools:call', 'mcp:billing:read']
},
submitDecision: noopSubmit
}
}
export const ClaudeDesktop: Story = {
args: {
initialChallenge: {
...baseChallenge,
client_display_name: 'Claude Desktop'
},
submitDecision: noopSubmit
}
}
export const WebClient: Story = {
args: {
initialChallenge: {
...baseChallenge,
client_display_name: 'Comfy Studio',
client_application_type: 'web',
redirect_uri: 'https://studio.example.com/oauth/cb'
},
submitDecision: noopSubmit
}
}
export const LegacyClientNoBadge: Story = {
args: {
// Pre-DCR seeded clients have application_type="" — UI should hide
// the badge entirely rather than guess.
initialChallenge: { ...baseChallenge, client_application_type: '' },
submitDecision: noopSubmit
}
}

View File

@@ -0,0 +1,230 @@
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import OAuthConsentView from '@/platform/cloud/oauth/OAuthConsentView.vue'
import { OAuthApiError } from '@/platform/cloud/oauth/oauthApi'
import type { OAuthConsentChallenge } from '@/platform/cloud/oauth/oauthApi'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
singleSelectDropdown: 'Select an option'
},
oauth: {
consent: {
allow: 'Continue',
deny: 'Cancel',
genericError: 'OAuth request failed.',
loading: 'Loading authorization request…',
missingRequest: 'This authorization request is missing.',
noWorkspaces: 'No eligible workspaces are available.',
title: '{client} wants access',
subtitle: 'Sign in to {resource} to continue',
workspaceLabel: 'Workspace',
permissionsHeader: 'Permissions',
workspaceHelp: 'Permissions apply to this workspace only.',
learnMore: 'Learn more',
redirectNotice: "You'll be redirected to",
appTypeNative: 'Native app',
appTypeWeb: 'Web app',
errorExpired:
'This consent request has expired or has already been used.',
errorScopeBroadening:
"The previously approved permissions don't cover this request.",
errorUnavailable: "This feature isn't available right now."
},
scopes: {
'mcp:tools:read': {
label: 'View available workflow tools'
},
'mcp:tools:call': {
label: 'Run workflows on your behalf'
}
},
workspace: {
personal: 'Personal',
team: 'Team',
owner: 'Owner',
member: 'Member'
}
}
}
}
})
const challenge: OAuthConsentChallenge = {
oauth_request_id: '550e8400-e29b-41d4-a716-446655440000',
csrf_token: 'csrf-token',
client_display_name: 'Cursor',
resource_display_name: 'ComfyUI MCP',
redirect_uri: 'http://127.0.0.1:50632/cb',
client_application_type: 'native',
scopes: ['mcp:tools:read', 'mcp:tools:call', 'mcp:unknown:test'],
workspaces: [
{
id: 'personal-workspace',
name: 'Personal',
type: 'personal',
role: 'owner'
},
{
id: 'team-workspace',
name: 'Team',
type: 'team',
role: 'member'
}
]
}
const renderConsent = (
overrides: Partial<OAuthConsentChallenge> = {},
submitDecision = vi.fn()
) =>
render(OAuthConsentView, {
global: { plugins: [i18n] },
props: {
initialChallenge: { ...challenge, ...overrides },
submitDecision
}
})
describe('OAuthConsentView', () => {
it('renders title, subtitle, and scope checklist', () => {
renderConsent()
// Title is "<client> wants access". Subtitle is "Sign in to <resource>
// to continue". Both are short and avoid repeating any brand name twice.
expect(screen.getByText('Cursor wants access')).toBeVisible()
expect(screen.getByText('Sign in to ComfyUI MCP to continue')).toBeVisible()
// Permissions section header is just the static word "Permissions".
expect(screen.getByText('Permissions')).toBeVisible()
// Known scopes render their human-readable labels. We deliberately
// avoid MCP jargon ("tools", "metadata") — the user thinks in
// ComfyUI vocabulary (workflows), and the consent UI doesn't show
// an enumerated tool list, so the label shouldn't promise one.
expect(screen.getByText('View available workflow tools')).toBeVisible()
expect(screen.getByText('Run workflows on your behalf')).toBeVisible()
// Unknown scopes fall back to the raw scope string so a new resource
// doesn't require a frontend release just to render its consent page.
expect(screen.getByText('mcp:unknown:test')).toBeVisible()
})
it('renders the registered redirect URI verbatim', () => {
renderConsent()
// Verbatim render — the user must be able to read the loopback URL
// and verify it's the localhost callback their CLI is listening on.
expect(screen.getByText('http://127.0.0.1:50632/cb')).toBeVisible()
expect(screen.getByText("You'll be redirected to")).toBeVisible()
})
it('renders a Native badge when client_application_type is "native"', () => {
renderConsent()
expect(screen.getByText('Native app')).toBeVisible()
})
it('renders a Web badge when client_application_type is "web"', () => {
renderConsent({ client_application_type: 'web' })
expect(screen.getByText('Web app')).toBeVisible()
})
it('hides the application-type badge for legacy seeded clients', () => {
renderConsent({ client_application_type: '' })
expect(screen.queryByText('Native app')).not.toBeInTheDocument()
expect(screen.queryByText('Web app')).not.toBeInTheDocument()
})
it('preselects the only workspace and submits with it', async () => {
const user = userEvent.setup()
const submitDecision = vi.fn()
renderConsent({ workspaces: [challenge.workspaces[0]] }, submitDecision)
// Single-workspace path: Allow is enabled and submission carries the
// sole workspace_id.
await user.click(screen.getByRole('button', { name: 'Continue' }))
expect(submitDecision).toHaveBeenCalledWith({
oauthRequestId: '550e8400-e29b-41d4-a716-446655440000',
csrfToken: 'csrf-token',
decision: 'allow',
workspaceId: 'personal-workspace'
})
})
it('keeps Allow disabled when multiple workspaces are available and none is chosen', () => {
renderConsent()
const allow = screen.getByRole('button', { name: 'Continue' })
expect(allow).toBeDisabled()
})
it('submits deny when the user cancels', async () => {
const user = userEvent.setup()
const submitDecision = vi.fn()
renderConsent({ workspaces: [challenge.workspaces[0]] }, submitDecision)
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(submitDecision).toHaveBeenCalledWith(
expect.objectContaining({
decision: 'deny',
workspaceId: 'personal-workspace'
})
)
})
it('maps OAuthApiError(400) to the expired-request message', async () => {
const submitDecision = vi
.fn()
.mockRejectedValue(new OAuthApiError('expired', 400))
const user = userEvent.setup()
renderConsent({ workspaces: [challenge.workspaces[0]] }, submitDecision)
await user.click(screen.getByRole('button', { name: 'Continue' }))
await waitFor(() => {
expect(
screen.getByText(
'This consent request has expired or has already been used.'
)
).toBeVisible()
})
})
it('maps OAuthApiError(403) to the scope-broadening re-prompt message', async () => {
const submitDecision = vi
.fn()
.mockRejectedValue(new OAuthApiError('scope broadening', 403))
const user = userEvent.setup()
renderConsent({ workspaces: [challenge.workspaces[0]] }, submitDecision)
await user.click(screen.getByRole('button', { name: 'Continue' }))
await waitFor(() => {
expect(
screen.getByText(
"The previously approved permissions don't cover this request."
)
).toBeVisible()
})
})
it('maps OAuthApiError(404) to the feature-unavailable message', async () => {
const submitDecision = vi
.fn()
.mockRejectedValue(new OAuthApiError('disabled', 404))
const user = userEvent.setup()
renderConsent({ workspaces: [challenge.workspaces[0]] }, submitDecision)
await user.click(screen.getByRole('button', { name: 'Continue' }))
await waitFor(() => {
expect(
screen.getByText("This feature isn't available right now.")
).toBeVisible()
})
})
})

View File

@@ -0,0 +1,331 @@
<template>
<main class="mx-auto flex min-h-screen max-w-md flex-col justify-center p-6">
<section
v-if="challenge"
class="flex flex-col gap-6 rounded-2xl border border-solid border-muted bg-(--p-content-background) p-6 shadow-sm"
>
<header class="flex flex-col items-center gap-3 pt-2 text-center">
<div
class="flex size-12 items-center justify-center rounded-2xl bg-secondary-background"
>
<i
class="pi pi-key text-xl text-base-foreground"
aria-hidden="true"
/>
</div>
<div class="flex flex-col items-center gap-1.5">
<h1 class="m-0 text-xl/tight font-semibold">
{{
t('oauth.consent.title', {
client: challenge.client_display_name
})
}}
</h1>
<p class="m-0 text-sm text-muted">
{{ t('oauth.consent.subtitle', { resource: resourceName }) }}
</p>
<span
v-if="appTypeBadge"
class="mt-1 inline-flex items-center gap-1 rounded-full border border-solid border-muted px-2 py-0.5 text-xs text-muted"
>
<i :class="appTypeBadge.icon" aria-hidden="true" />
{{ appTypeBadge.label }}
</span>
</div>
</header>
<section class="flex flex-col gap-2">
<p class="m-0 text-sm font-medium">
{{ t('oauth.consent.workspaceLabel') }}
</p>
<div
v-if="challenge.workspaces.length === 0"
class="p-3 text-sm text-muted"
>
{{ t('oauth.consent.noWorkspaces') }}
</div>
<ul
v-else
role="radiogroup"
:aria-label="t('oauth.consent.workspaceLabel')"
class="m-0 flex scrollbar-custom max-h-72 list-none flex-col gap-1 overflow-y-auto p-0"
>
<li v-for="workspace in challenge.workspaces" :key="workspace.id">
<button
type="button"
role="radio"
:aria-checked="selectedWorkspaceId === workspace.id"
:class="
cn(
'flex w-full cursor-pointer items-center gap-3 rounded-md border-none bg-transparent px-3 py-2 text-left transition-colors',
'hover:bg-secondary-background-hover',
'focus-visible:ring-ring focus-visible:ring-1 focus-visible:outline-none',
selectedWorkspaceId === workspace.id &&
'bg-secondary-background'
)
"
@click="selectedWorkspaceId = workspace.id"
>
<WorkspaceProfilePic
class="size-8 shrink-0 text-sm"
:workspace-name="workspace.name"
/>
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm text-base-foreground">
{{ workspace.name }}
</span>
<span class="text-xs text-muted-foreground">
{{ workspaceSecondaryLabel(workspace) }}
</span>
</div>
<i
v-if="selectedWorkspaceId === workspace.id"
class="pi pi-check shrink-0 text-sm text-base-foreground"
aria-hidden="true"
/>
</button>
</li>
</ul>
<p class="m-0 text-xs text-muted">
{{ t('oauth.consent.workspaceHelp') }}
</p>
</section>
<section class="flex flex-col gap-3">
<p class="m-0 text-sm font-medium">
{{ t('oauth.consent.permissionsHeader') }}
</p>
<ul class="m-0 flex list-none flex-col gap-1.5 p-0">
<li
v-for="scope in challenge.scopes"
:key="scope"
class="flex items-center gap-2"
>
<i
class="pi pi-check shrink-0 text-sm text-primary-background"
aria-hidden="true"
/>
<span class="text-sm">
{{ scopeLabel(scope) }}
</span>
</li>
</ul>
</section>
<section
v-if="challenge.redirect_uri"
class="flex flex-col gap-1.5 rounded-lg border border-solid border-muted bg-secondary-background/40 p-3"
>
<span class="text-xs text-muted">
{{ t('oauth.consent.redirectNotice') }}
</span>
<code
class="m-0 truncate font-mono text-xs text-base-foreground"
:title="challenge.redirect_uri"
>
{{ challenge.redirect_uri }}
</code>
</section>
<p
v-if="errorMessage"
role="alert"
class="m-0 rounded-md border border-solid border-destructive-background bg-destructive-background/10 p-3 text-sm text-destructive-background"
>
{{ errorMessage }}
</p>
<footer class="flex flex-col gap-2">
<Button
variant="primary"
size="lg"
class="w-full"
:loading="isSubmitting && lastDecision === 'allow'"
:disabled="isSubmitting || !canSubmit"
@click="submit('allow')"
>
{{ t('oauth.consent.allow') }}
</Button>
<Button
variant="secondary"
size="lg"
class="w-full"
:loading="isSubmitting && lastDecision === 'deny'"
:disabled="isSubmitting"
@click="submit('deny')"
>
{{ t('oauth.consent.deny') }}
</Button>
</footer>
</section>
<p
v-else-if="errorMessage"
role="alert"
class="m-0 rounded-md border border-solid border-destructive-background bg-destructive-background/10 p-3 text-center text-sm text-destructive-background"
>
{{ errorMessage }}
</p>
<p v-else class="m-0 text-center text-sm text-muted">
{{ t('oauth.consent.loading') }}
</p>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import Button from '@/components/ui/button/Button.vue'
import {
OAuthApiError,
fetchOAuthConsentChallenge,
submitOAuthConsentDecision
} from '@/platform/cloud/oauth/oauthApi'
import type {
OAuthConsentChallenge,
OAuthConsentDecision,
OAuthWorkspace
} from '@/platform/cloud/oauth/oauthApi'
import {
clearOAuthRequestId,
getOAuthRequestId
} from '@/platform/cloud/oauth/oauthState'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
import { cn } from '@comfyorg/tailwind-utils'
const { initialChallenge, submitDecision = submitOAuthConsentDecision } =
defineProps<{
initialChallenge?: OAuthConsentChallenge
submitDecision?: OAuthConsentDecision
}>()
const { t, te } = useI18n()
const route = useRoute()
const challenge = ref<OAuthConsentChallenge | null>(initialChallenge ?? null)
const selectedWorkspaceId = ref<string | undefined>(
initialChallenge?.workspaces.length === 1
? initialChallenge.workspaces[0].id
: undefined
)
const errorMessage = ref('')
const isSubmitting = ref(false)
const lastDecision = ref<'allow' | 'deny' | null>(null)
const resourceName = computed(
() =>
challenge.value?.resource_display_name ??
t('oauth.consent.resourceFallback')
)
const appTypeBadge = computed(() => {
const appType = challenge.value?.client_application_type
if (appType === 'native') {
return { label: t('oauth.consent.appTypeNative'), icon: 'pi pi-desktop' }
}
if (appType === 'web') {
return { label: t('oauth.consent.appTypeWeb'), icon: 'pi pi-globe' }
}
return null
})
const selectedWorkspaceIsValid = computed(() =>
Boolean(
selectedWorkspaceId.value &&
challenge.value?.workspaces.some(
(workspace) => workspace.id === selectedWorkspaceId.value
)
)
)
const canSubmit = computed(() => selectedWorkspaceIsValid.value)
function scopeLabel(scope: string): string {
const key = `oauth.scopes.${scope}.label`
return te(key) ? t(key) : scope
}
function labelFor(value: string): string {
const key = `oauth.workspace.${value}`
return te(key) ? t(key) : value
}
// Row's secondary label: personal workspaces show "Personal" (role is
// always implicit owner); team workspaces show the role ("Owner"/"Member").
function workspaceSecondaryLabel(workspace: OAuthWorkspace): string {
return workspace.type === 'personal'
? labelFor('personal')
: labelFor(workspace.role)
}
function requestIdFromRoute(): string | null {
return typeof route.query.oauth_request_id === 'string'
? route.query.oauth_request_id
: getOAuthRequestId()
}
function initializeWorkspaceSelection(nextChallenge: OAuthConsentChallenge) {
selectedWorkspaceId.value =
nextChallenge.workspaces.length === 1
? nextChallenge.workspaces[0].id
: undefined
}
async function loadChallenge() {
const oauthRequestId = requestIdFromRoute()
if (!oauthRequestId) {
errorMessage.value = t('oauth.consent.missingRequest')
return
}
try {
const next = await fetchOAuthConsentChallenge(oauthRequestId)
challenge.value = next
initializeWorkspaceSelection(next)
} catch (error) {
errorMessage.value = messageForError(error)
}
}
function messageForError(error: unknown): string {
if (error instanceof OAuthApiError) {
if (error.status === 400) return t('oauth.consent.errorExpired')
if (error.status === 403) return t('oauth.consent.errorScopeBroadening')
if (error.status === 404) return t('oauth.consent.errorUnavailable')
}
return error instanceof Error
? error.message
: t('oauth.consent.genericError')
}
async function submit(decision: 'allow' | 'deny') {
if (!challenge.value) return
if (decision === 'allow' && !selectedWorkspaceIsValid.value) return
errorMessage.value = ''
isSubmitting.value = true
lastDecision.value = decision
try {
await submitDecision({
oauthRequestId: challenge.value.oauth_request_id,
csrfToken: challenge.value.csrf_token,
decision,
// Cloud requires workspace_id on both allow and deny.
workspaceId:
selectedWorkspaceId.value ?? challenge.value.workspaces[0]?.id ?? ''
})
clearOAuthRequestId()
} catch (error) {
errorMessage.value = messageForError(error)
} finally {
isSubmitting.value = false
lastDecision.value = null
}
}
onMounted(() => {
if (!initialChallenge) {
void loadChallenge()
}
})
</script>

View File

@@ -0,0 +1,145 @@
export type OAuthWorkspace = {
id: string
name: string
type: 'personal' | 'team'
role: 'owner' | 'member'
}
export type OAuthConsentChallenge = {
oauth_request_id: string
csrf_token: string
client_display_name: string
resource_display_name?: string
/**
* Exact registered redirect URI the OAuth client will be sent to on
* success/deny. Surfaced verbatim so users can verify the destination
* (RFC 8252 loopback for CLIs, HTTPS for web clients).
*/
redirect_uri?: string
/**
* RFC 7591 application_type — "native" (CLI/desktop, loopback redirect),
* "web" (HTTPS-hosted), or "" for legacy seeded clients. Used to render
* a Native / Web badge so users know what kind of app they're authorizing.
*/
client_application_type?: 'native' | 'web' | ''
scopes: string[]
workspaces: OAuthWorkspace[]
}
export type OAuthConsentDecisionParams = {
oauthRequestId: string
csrfToken: string
decision: 'allow' | 'deny'
workspaceId: string
}
export type OAuthConsentDecision = (
params: OAuthConsentDecisionParams
) => Promise<void>
type OAuthDecisionResponse = {
redirect_url?: string
}
export class OAuthApiError extends Error {
constructor(
message: string,
readonly status: number
) {
super(message)
this.name = 'OAuthApiError'
}
}
function oauthUrl(path: string): string {
// Relative URL — let the Vite dev-server proxy (same origin as the FE)
// or the production same-host deploy hit ingest.
//
// Going direct cross-origin via VITE_CLOUD_INGEST_ORIGIN is a footgun:
// useSessionCookie POSTs /api/auth/session through the proxy, so the
// Set-Cookie response lands on the FE origin. A cross-origin fetch to
// a different cloud host wouldn't include that cookie, so the consent
// challenge would 302 to login (and trip browser cross-origin redirect
// rules to boot — the symptom looks like "CORS error" on a fetch
// initiated from /oauth/authorize).
//
// Keep all OAuth calls same-origin. The Vite proxy / production
// ingress is the single point of routing.
return path
}
async function readErrorMessage(response: Response): Promise<string> {
const body = await response.json().catch(() => null)
return body?.message ?? response.statusText
}
function assertChallenge(
value: unknown
): asserts value is OAuthConsentChallenge {
if (typeof value !== 'object' || value === null) {
throw new Error('OAuth consent challenge is invalid')
}
const challenge = value as Partial<OAuthConsentChallenge>
if (
typeof challenge.oauth_request_id !== 'string' ||
typeof challenge.csrf_token !== 'string' ||
typeof challenge.client_display_name !== 'string' ||
!Array.isArray(challenge.scopes) ||
!Array.isArray(challenge.workspaces)
) {
throw new Error('OAuth consent challenge is invalid')
}
}
export async function fetchOAuthConsentChallenge(
oauthRequestId: string
): Promise<OAuthConsentChallenge> {
const response = await fetch(
oauthUrl(`/oauth/authorize?oauth_request_id=${oauthRequestId}`),
{
method: 'GET',
credentials: 'include'
}
)
if (!response.ok) {
throw new OAuthApiError(await readErrorMessage(response), response.status)
}
const challenge: unknown = await response.json()
assertChallenge(challenge)
return challenge
}
export async function submitOAuthConsentDecision({
oauthRequestId,
csrfToken,
decision,
workspaceId
}: OAuthConsentDecisionParams): Promise<void> {
const response = await fetch(oauthUrl('/oauth/authorize'), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
oauth_request_id: oauthRequestId,
csrf_token: csrfToken,
decision,
workspace_id: workspaceId
})
})
if (!response.ok) {
throw new OAuthApiError(await readErrorMessage(response), response.status)
}
const body: OAuthDecisionResponse = await response.json()
if (!body.redirect_url) {
throw new Error('OAuth consent response did not include redirect_url')
}
globalThis.location.href = body.redirect_url
}

View File

@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
captureOAuthRequestId,
clearOAuthRequestId,
getOAuthRequestId,
hasOAuthRequestId
} from '@/platform/cloud/oauth/oauthState'
describe('oauthState', () => {
beforeEach(() => {
sessionStorage.clear()
clearOAuthRequestId()
})
it('captures a valid oauth_request_id only', () => {
captureOAuthRequestId({
oauth_request_id: '550e8400-e29b-41d4-a716-446655440000',
client_id: 'must-not-be-stored'
})
expect(getOAuthRequestId()).toBe('550e8400-e29b-41d4-a716-446655440000')
expect(hasOAuthRequestId()).toBe(true)
expect(sessionStorage.getItem('Comfy.OAuthRequestId')).toBe(
'550e8400-e29b-41d4-a716-446655440000'
)
})
it('ignores missing, repeated, and invalid request ids', () => {
captureOAuthRequestId({})
expect(getOAuthRequestId()).toBeNull()
captureOAuthRequestId({ oauth_request_id: ['a', 'b'] })
expect(getOAuthRequestId()).toBeNull()
captureOAuthRequestId({ oauth_request_id: 'not-a-uuid' })
expect(getOAuthRequestId()).toBeNull()
})
it('hydrates from session storage and clears after completion', () => {
sessionStorage.setItem(
'Comfy.OAuthRequestId',
'550e8400-e29b-41d4-a716-446655440000'
)
expect(getOAuthRequestId()).toBe('550e8400-e29b-41d4-a716-446655440000')
clearOAuthRequestId()
expect(getOAuthRequestId()).toBeNull()
expect(sessionStorage.getItem('Comfy.OAuthRequestId')).toBeNull()
})
})

View File

@@ -0,0 +1,38 @@
import type { LocationQuery } from 'vue-router'
const OAUTH_REQUEST_ID_STORAGE_KEY = 'Comfy.OAuthRequestId'
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
function readQueryString(value: LocationQuery[string]): string | null {
return typeof value === 'string' ? value : null
}
function isOAuthRequestId(value: string): boolean {
return UUID_PATTERN.test(value)
}
function readStoredOAuthRequestId(): string | null {
const value = sessionStorage.getItem(OAUTH_REQUEST_ID_STORAGE_KEY)
return value && isOAuthRequestId(value) ? value : null
}
export function captureOAuthRequestId(query: LocationQuery): string | null {
const value = readQueryString(query.oauth_request_id)
if (!value || !isOAuthRequestId(value)) return null
sessionStorage.setItem(OAUTH_REQUEST_ID_STORAGE_KEY, value)
return value
}
export function getOAuthRequestId(): string | null {
return readStoredOAuthRequestId()
}
export function hasOAuthRequestId(): boolean {
return getOAuthRequestId() !== null
}
export function clearOAuthRequestId(): void {
sessionStorage.removeItem(OAUTH_REQUEST_ID_STORAGE_KEY)
}

View File

@@ -116,6 +116,11 @@ import { useRoute, useRouter } from 'vue-router'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useSessionCookie } from '@/platform/auth/session/useSessionCookie'
import {
captureOAuthRequestId,
getOAuthRequestId
} from '@/platform/cloud/oauth/oauthState'
import CloudSignInForm from '@/platform/cloud/onboarding/components/CloudSignInForm.vue'
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
@@ -127,6 +132,7 @@ const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const authActions = useAuthActions()
const sessionCookie = useSessionCookie()
const isSecureContext = globalThis.isSecureContext
const authError = ref('')
const toastStore = useToastStore()
@@ -153,6 +159,17 @@ const onSuccess = async () => {
life: 2000
})
captureOAuthRequestId(route.query)
const oauthRequestId = getOAuthRequestId()
if (oauthRequestId) {
await sessionCookie.createSessionOrThrow()
await router.push({
name: 'cloud-oauth-consent',
query: { oauth_request_id: oauthRequestId }
})
return
}
const previousFullPath = getSafePreviousFullPath(route.query)
if (previousFullPath) {
await router.replace(previousFullPath)

View File

@@ -141,6 +141,11 @@ import { useRoute, useRouter } from 'vue-router'
import SignUpForm from '@/components/dialog/content/signin/SignUpForm.vue'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useSessionCookie } from '@/platform/auth/session/useSessionCookie'
import {
captureOAuthRequestId,
getOAuthRequestId
} from '@/platform/cloud/oauth/oauthState'
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
import { isCloud } from '@/platform/distribution/types'
@@ -154,6 +159,7 @@ const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const authActions = useAuthActions()
const sessionCookie = useSessionCookie()
const isSecureContext = globalThis.isSecureContext
const authError = ref('')
const userIsInChina = ref(false)
@@ -179,6 +185,17 @@ const onSuccess = async () => {
life: 2000
})
captureOAuthRequestId(route.query)
const oauthRequestId = getOAuthRequestId()
if (oauthRequestId) {
await sessionCookie.createSessionOrThrow()
await router.push({
name: 'cloud-oauth-consent',
query: { oauth_request_id: oauthRequestId }
})
return
}
const previousFullPath = getSafePreviousFullPath(route.query)
if (previousFullPath) {
await router.replace(previousFullPath)

View File

@@ -1,5 +1,20 @@
import type { RouteRecordRaw } from 'vue-router'
import {
captureOAuthRequestId,
getOAuthRequestId
} from '@/platform/cloud/oauth/oauthState'
function oauthConsentRedirect() {
const oauthRequestId = getOAuthRequestId()
return oauthRequestId
? {
name: 'cloud-oauth-consent',
query: { oauth_request_id: oauthRequestId }
}
: { name: 'cloud-user-check' }
}
export const cloudOnboardingRoutes: RouteRecordRaw[] = [
{
path: '/cloud',
@@ -12,6 +27,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
component: () =>
import('@/platform/cloud/onboarding/CloudLoginView.vue'),
beforeEnter: async (to, _from, next) => {
captureOAuthRequestId(to.query)
// Only redirect if not explicitly switching accounts
if (!to.query.switchAccount) {
const { useCurrentUser } =
@@ -19,9 +35,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
const { isLoggedIn } = useCurrentUser()
if (isLoggedIn.value) {
// User is already logged in, redirect to user-check
// user-check will handle survey, or main page routing
return next({ name: 'cloud-user-check' })
return next(oauthConsentRedirect())
}
}
next()
@@ -33,13 +47,14 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
component: () =>
import('@/platform/cloud/onboarding/CloudSignupView.vue'),
beforeEnter: async (to, _from, next) => {
captureOAuthRequestId(to.query)
if (!to.query.switchAccount) {
const { useCurrentUser } =
await import('@/composables/auth/useCurrentUser')
const { isLoggedIn } = useCurrentUser()
if (isLoggedIn.value) {
return next({ name: 'cloud-user-check' })
return next(oauthConsentRedirect())
}
}
next()
@@ -58,6 +73,11 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
import('@/platform/cloud/onboarding/CloudSurveyView.vue'),
meta: { requiresAuth: true }
},
{
path: 'oauth/consent',
name: 'cloud-oauth-consent',
component: () => import('@/platform/cloud/oauth/OAuthConsentView.vue')
},
{
path: 'user-check',
name: 'cloud-user-check',

View File

@@ -2,5 +2,6 @@ export const PRESERVED_QUERY_NAMESPACES = {
TEMPLATE: 'template',
INVITE: 'invite',
SHARE: 'share',
CREATE_WORKSPACE: 'create_workspace'
CREATE_WORKSPACE: 'create_workspace',
OAUTH: 'oauth'
} as const

View File

@@ -75,6 +75,7 @@ const waitForMixpanelInit = () =>
vi.waitFor(() => expect(mockMixpanel.init).toHaveBeenCalled())
type ConfigWindow = { __CONFIG__?: { mixpanel_token?: string } }
type DefineGlobal = typeof globalThis & { __DEV_SERVER_COMFYUI_URL__: string }
describe('MixpanelTelemetryProvider — without configured token', () => {
beforeEach(() => {
@@ -103,6 +104,7 @@ describe('MixpanelTelemetryProvider — without configured token', () => {
describe('MixpanelTelemetryProvider — with configured token', () => {
beforeEach(() => {
vi.clearAllMocks()
;(globalThis as DefineGlobal).__DEV_SERVER_COMFYUI_URL__ = ''
;(window as unknown as ConfigWindow).__CONFIG__ = {
mixpanel_token: 'test-token'
}
@@ -112,6 +114,26 @@ describe('MixpanelTelemetryProvider — with configured token', () => {
mockNormalizeSurveyResponses.mockImplementation((responses) => responses)
})
it('does not initialize Mixpanel for loopback cloud dev', () => {
;(globalThis as DefineGlobal).__DEV_SERVER_COMFYUI_URL__ =
'http://localhost:8188'
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
const provider = new MixpanelTelemetryProvider()
provider.trackUserLoggedIn()
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('disabled in local cloud dev')
)
expect(mockMixpanel.init).not.toHaveBeenCalled()
expect(mockMixpanel.track).not.toHaveBeenCalled()
} finally {
warn.mockRestore()
;(globalThis as DefineGlobal).__DEV_SERVER_COMFYUI_URL__ = ''
}
})
it('initializes Mixpanel and tracks events synchronously after the loaded callback fires', async () => {
const provider = new MixpanelTelemetryProvider()
await waitForMixpanelInit()

View File

@@ -69,6 +69,8 @@ const DEFAULT_DISABLED_EVENTS = [
const TELEMETRY_EVENT_SET = new Set<TelemetryEventName>(
Object.values(TelemetryEvents) as TelemetryEventName[]
)
const localOriginPattern =
/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/|$)/
interface QueuedEvent {
eventName: TelemetryEventName
@@ -96,6 +98,12 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
constructor() {
if (isLocalCloudDev()) {
console.warn('Mixpanel telemetry disabled in local cloud dev')
this.isEnabled = false
return
}
this.configureDisabledEvents(
(window.__CONFIG__ as Partial<RemoteConfig> | undefined) ?? null
)
@@ -446,3 +454,9 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.UI_BUTTON_CLICKED, metadata)
}
}
function isLocalCloudDev(): boolean {
return (
import.meta.env.DEV && localOriginPattern.test(__DEV_SERVER_COMFYUI_URL__)
)
}

View File

@@ -15,6 +15,7 @@ import { useAuthStore } from '@/stores/authStore'
import { useUserStore } from '@/stores/userStore'
import LayoutDefault from '@/views/layouts/LayoutDefault.vue'
import { captureOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
import { installPreservedQueryTracker } from '@/platform/navigation/preservedQueryTracker'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
@@ -110,9 +111,18 @@ installPreservedQueryTracker(router, [
{
namespace: PRESERVED_QUERY_NAMESPACES.CREATE_WORKSPACE,
keys: ['create_workspace']
},
{
namespace: PRESERVED_QUERY_NAMESPACES.OAUTH,
keys: ['oauth_request_id']
}
])
router.beforeEach((to, _from, next) => {
captureOAuthRequestId(to.query)
next()
})
router.afterEach(() => {
trackPageView()
})
@@ -123,12 +133,14 @@ if (isCloud) {
'cloud-login',
'cloud-signup',
'cloud-forgot-password',
'cloud-oauth-consent',
'cloud-sorry-contact-support'
])
const PUBLIC_ROUTE_PATHS = new Set([
'/cloud/login',
'/cloud/signup',
'/cloud/forgot-password',
'/cloud/oauth/consent',
'/cloud/sorry-contact-support'
])

View File

@@ -88,6 +88,19 @@ const DEV_SEVER_FALLBACK_URL =
const DEV_SERVER_COMFYUI_URL =
DEV_SERVER_COMFYUI_ENV_URL || DEV_SEVER_FALLBACK_URL
/** When cloud dev proxies the frontend at :5173 to a loopback ingest, registry routes must not hit ingest. */
const LOCAL_LOOPBACK_COMFY_BACKEND_PATTERN =
/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/|$)/i
const CLOUD_REGISTRY_API_TARGET =
process.env.USE_PROD_CONFIG === 'true'
? 'https://api.comfy.org'
: 'https://stagingapi.comfy.org'
const PROXY_REGISTRY_FOR_LOCAL_CLOUD =
DISTRIBUTION === 'cloud' &&
LOCAL_LOOPBACK_COMFY_BACKEND_PATTERN.test(DEV_SERVER_COMFYUI_URL)
const cloudProxyConfig =
DISTRIBUTION === 'cloud' ? { secure: false, changeOrigin: true } : {}
@@ -203,6 +216,14 @@ export default defineConfig({
return false
}
// Cloud ingest does not host OSS ComfyUI /api/i18n; avoid hard-failing bootstrap.
const pathOnly = req.url?.split('?')[0]
if (DISTRIBUTION === 'cloud' && pathOnly === '/api/i18n') {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({}))
return false
}
// Bypass multi-user auth check from staging (cloud only)
if (DISTRIBUTION === 'cloud' && req.url === '/api/users') {
res.setHeader('Content-Type', 'application/json')
@@ -214,6 +235,26 @@ export default defineConfig({
}
},
'/oauth': {
target: DEV_SERVER_COMFYUI_URL,
...cloudProxyConfig
},
...(PROXY_REGISTRY_FOR_LOCAL_CLOUD
? {
'/customers': {
target: CLOUD_REGISTRY_API_TARGET,
changeOrigin: true,
secure: true
},
'/releases': {
target: CLOUD_REGISTRY_API_TARGET,
changeOrigin: true,
secure: true
}
}
: {}),
'/ws': {
target: DEV_SERVER_COMFYUI_URL,
ws: true,
@@ -625,6 +666,7 @@ export default defineConfig({
__ALGOLIA_APP_ID__: JSON.stringify(process.env.ALGOLIA_APP_ID || ''),
__ALGOLIA_API_KEY__: JSON.stringify(process.env.ALGOLIA_API_KEY || ''),
__USE_PROD_CONFIG__: process.env.USE_PROD_CONFIG === 'true',
__DEV_SERVER_COMFYUI_URL__: JSON.stringify(DEV_SERVER_COMFYUI_URL),
__DISTRIBUTION__: JSON.stringify(DISTRIBUTION),
__IS_NIGHTLY__: JSON.stringify(IS_NIGHTLY)
},

View File

@@ -46,6 +46,7 @@ globalThis.__SENTRY_DSN__ = ''
globalThis.__ALGOLIA_APP_ID__ = ''
globalThis.__ALGOLIA_API_KEY__ = ''
globalThis.__USE_PROD_CONFIG__ = false
globalThis.__DEV_SERVER_COMFYUI_URL__ = ''
globalThis.__DISTRIBUTION__ = 'localhost'
globalThis.__IS_NIGHTLY__ = false