mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 21:38:52 +00:00
Compare commits
5 Commits
jaewon/fix
...
kishore/oa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f789e869e0 | ||
|
|
9222f057c4 | ||
|
|
ca6b699a3d | ||
|
|
635a75871b | ||
|
|
8264105116 |
1
global.d.ts
vendored
1
global.d.ts
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
26
src/config/comfyApi.test.ts
Normal file
26
src/config/comfyApi.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
37
src/config/firebase.test.ts
Normal file
37
src/config/firebase.test.ts
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2126,6 +2126,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",
|
||||
|
||||
14
src/main.ts
14
src/main.ts
@@ -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()
|
||||
|
||||
84
src/platform/auth/session/useSessionCookie.test.ts
Normal file
84
src/platform/auth/session/useSessionCookie.test.ts
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
117
src/platform/cloud/oauth/OAuthConsentView.stories.ts
Normal file
117
src/platform/cloud/oauth/OAuthConsentView.stories.ts
Normal 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
|
||||
}
|
||||
}
|
||||
230
src/platform/cloud/oauth/OAuthConsentView.test.ts
Normal file
230
src/platform/cloud/oauth/OAuthConsentView.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
331
src/platform/cloud/oauth/OAuthConsentView.vue
Normal file
331
src/platform/cloud/oauth/OAuthConsentView.vue
Normal 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>
|
||||
145
src/platform/cloud/oauth/oauthApi.ts
Normal file
145
src/platform/cloud/oauth/oauthApi.ts
Normal 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
|
||||
}
|
||||
53
src/platform/cloud/oauth/oauthState.test.ts
Normal file
53
src/platform/cloud/oauth/oauthState.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
38
src/platform/cloud/oauth/oauthState.ts
Normal file
38
src/platform/cloud/oauth/oauthState.ts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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__)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user