diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 9b1855e4c9..f9dd7da4b3 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2129,6 +2129,19 @@ "slots": "Node Slots Error", "widgets": "Node Widgets Error" }, + "oauth": { + "consent": { + "allow": "Allow", + "deny": "Deny", + "genericError": "OAuth request failed. Please restart from the client app.", + "loading": "Loading OAuth request...", + "missingRequest": "This OAuth request is missing. Please restart from the client app.", + "noWorkspaces": "No eligible workspaces are available for this request.", + "resourceLabel": "Authorize this app", + "scopesTitle": "Requested access", + "workspaceTitle": "Choose workspace" + } + }, "auth": { "apiKey": { "title": "API Key", diff --git a/src/platform/auth/session/useSessionCookie.test.ts b/src/platform/auth/session/useSessionCookie.test.ts new file mode 100644 index 0000000000..4a940ad408 --- /dev/null +++ b/src/platform/auth/session/useSessionCookie.test.ts @@ -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' + ) + }) +}) diff --git a/src/platform/auth/session/useSessionCookie.ts b/src/platform/auth/session/useSessionCookie.ts index 03806c625b..0ea2ac32bb 100644 --- a/src/platform/auth/session/useSessionCookie.ts +++ b/src/platform/auth/session/useSessionCookie.ts @@ -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 + ): Promise => { + return await fetch(api.apiURL('/auth/session'), { + method: 'POST', + credentials: 'include', + headers: { + ...authHeader, + 'Content-Type': 'application/json' + } + }) + } + + const readSessionError = async (response: Response): Promise => { + const errorData = await response.json().catch(() => ({})) + return errorData.message || response.statusText + } + + const getFirebaseSessionHeaderOrThrow = async (): Promise< + Record + > => { + 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 => { + 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 => { + clearOAuthRequestId() if (!isCloud) return try { @@ -95,6 +128,7 @@ export const useSessionCookie = () => { return { createSession, + createSessionOrThrow, deleteSession } } diff --git a/src/platform/cloud/oauth/OAuthConsentView.stories.ts b/src/platform/cloud/oauth/OAuthConsentView.stories.ts new file mode 100644 index 0000000000..86a68098f7 --- /dev/null +++ b/src/platform/cloud/oauth/OAuthConsentView.stories.ts @@ -0,0 +1,41 @@ +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 previewChallenge: OAuthConsentChallenge = { + oauth_request_id: '550e8400-e29b-41d4-a716-446655440000', + csrf_token: 'preview-csrf-token', + client_display_name: 'Cursor', + resource_display_name: 'ComfyUI MCP', + scopes: ['mcp:tools:read', 'mcp:tools:call', 'mcp:unknown:test'], + workspaces: [ + { + id: 'personal-workspace', + name: 'Personal Workspace', + type: 'personal', + role: 'owner' + }, + { + id: 'team-workspace', + name: 'Team Workspace', + type: 'team', + role: 'member' + } + ] +} + +const meta: Meta = { + title: 'Cloud/OAuth/Consent', + component: OAuthConsentView +} + +export default meta +type Story = StoryObj + +export const AllMcpScopes: Story = { + args: { + initialChallenge: previewChallenge, + submitDecision: async () => {} + } +} diff --git a/src/platform/cloud/oauth/OAuthConsentView.test.ts b/src/platform/cloud/oauth/OAuthConsentView.test.ts new file mode 100644 index 0000000000..aac50cc180 --- /dev/null +++ b/src/platform/cloud/oauth/OAuthConsentView.test.ts @@ -0,0 +1,136 @@ +import { render, screen } 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 type { OAuthConsentChallenge } from '@/platform/cloud/oauth/oauthApi' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + oauth: { + consent: { + allow: 'Allow', + deny: 'Deny', + genericError: 'OAuth request failed.', + loading: 'Loading OAuth request...', + missingRequest: 'This OAuth request is missing.', + noWorkspaces: 'No eligible workspaces are available.', + resourceLabel: 'Authorize this app', + scopesTitle: 'Requested access', + workspaceTitle: 'Choose workspace' + } + } + } + } +}) + +const challenge: OAuthConsentChallenge = { + oauth_request_id: '550e8400-e29b-41d4-a716-446655440000', + csrf_token: 'csrf-token', + client_display_name: 'Cursor', + resource_display_name: 'ComfyUI MCP', + 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 = {}, + submitDecision = vi.fn() +) => + render(OAuthConsentView, { + global: { plugins: [i18n] }, + props: { + initialChallenge: { ...challenge, ...overrides }, + submitDecision + } + }) + +describe('OAuthConsentView', () => { + it('renders client, resource, and scopes from the challenge', () => { + renderConsent() + + expect(screen.getByText('Cursor')).toBeVisible() + expect(screen.getByText('ComfyUI MCP')).toBeVisible() + expect(screen.getByText('mcp:tools:read')).toBeVisible() + expect(screen.getByText('mcp:tools:call')).toBeVisible() + expect(screen.getByText('mcp:unknown:test')).toBeVisible() + }) + + it('requires workspace selection when multiple workspaces are available', async () => { + const user = userEvent.setup() + const submitDecision = vi.fn() + renderConsent({}, submitDecision) + + const allow = screen.getByRole('button', { name: 'Allow' }) + expect(allow).toBeDisabled() + + await user.click(screen.getByLabelText(/Team/)) + expect(allow).toBeEnabled() + + await user.click(allow) + + expect(submitDecision).toHaveBeenCalledWith({ + oauthRequestId: '550e8400-e29b-41d4-a716-446655440000', + csrfToken: 'csrf-token', + decision: 'allow', + workspaceId: 'team-workspace' + }) + }) + + it('renders a single workspace read-only without auto-submitting', async () => { + const user = userEvent.setup() + const submitDecision = vi.fn() + renderConsent( + { + workspaces: [challenge.workspaces[0]] + }, + submitDecision + ) + + expect(screen.getByText('Personal')).toBeVisible() + expect(screen.queryByRole('radio')).not.toBeInTheDocument() + expect(submitDecision).not.toHaveBeenCalled() + + await user.click(screen.getByRole('button', { name: 'Allow' })) + + expect(submitDecision).toHaveBeenCalledWith({ + oauthRequestId: '550e8400-e29b-41d4-a716-446655440000', + csrfToken: 'csrf-token', + decision: 'allow', + workspaceId: 'personal-workspace' + }) + }) + + it('submits deny with the selected workspace', async () => { + const user = userEvent.setup() + const submitDecision = vi.fn() + renderConsent({}, submitDecision) + + await user.click(screen.getByLabelText(/Team/)) + await user.click(screen.getByRole('button', { name: 'Deny' })) + + expect(submitDecision).toHaveBeenCalledWith({ + oauthRequestId: '550e8400-e29b-41d4-a716-446655440000', + csrfToken: 'csrf-token', + decision: 'deny', + workspaceId: 'team-workspace' + }) + }) +}) diff --git a/src/platform/cloud/oauth/OAuthConsentView.vue b/src/platform/cloud/oauth/OAuthConsentView.vue new file mode 100644 index 0000000000..d8ba77efd6 --- /dev/null +++ b/src/platform/cloud/oauth/OAuthConsentView.vue @@ -0,0 +1,202 @@ + + + diff --git a/src/platform/cloud/oauth/oauthApi.ts b/src/platform/cloud/oauth/oauthApi.ts new file mode 100644 index 0000000000..40a07ada24 --- /dev/null +++ b/src/platform/cloud/oauth/oauthApi.ts @@ -0,0 +1,125 @@ +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 + scopes: string[] + workspaces: OAuthWorkspace[] +} + +export type OAuthConsentDecisionParams = { + oauthRequestId: string + csrfToken: string + decision: 'allow' | 'deny' + workspaceId: string +} + +export type OAuthConsentDecision = ( + params: OAuthConsentDecisionParams +) => Promise + +type OAuthDecisionResponse = { + redirect_url?: string +} + +export class OAuthApiError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message) + this.name = 'OAuthApiError' + } +} + +function getOAuthOrigin(): string { + return import.meta.env.VITE_CLOUD_INGEST_ORIGIN ?? '' +} + +function oauthUrl(path: string): string { + const origin = getOAuthOrigin() + return origin ? new URL(path, origin).toString() : path +} + +async function readErrorMessage(response: Response): Promise { + 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 + 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 { + 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 { + 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 +} diff --git a/src/platform/cloud/oauth/oauthState.test.ts b/src/platform/cloud/oauth/oauthState.test.ts new file mode 100644 index 0000000000..578afe60a5 --- /dev/null +++ b/src/platform/cloud/oauth/oauthState.test.ts @@ -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() + }) +}) diff --git a/src/platform/cloud/oauth/oauthState.ts b/src/platform/cloud/oauth/oauthState.ts new file mode 100644 index 0000000000..bda14a6479 --- /dev/null +++ b/src/platform/cloud/oauth/oauthState.ts @@ -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) +} diff --git a/src/platform/cloud/onboarding/CloudLoginView.vue b/src/platform/cloud/onboarding/CloudLoginView.vue index ce0ec8f790..8c0fc3ded5 100644 --- a/src/platform/cloud/onboarding/CloudLoginView.vue +++ b/src/platform/cloud/onboarding/CloudLoginView.vue @@ -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) diff --git a/src/platform/cloud/onboarding/CloudSignupView.vue b/src/platform/cloud/onboarding/CloudSignupView.vue index 65fd210277..02ac59a525 100644 --- a/src/platform/cloud/onboarding/CloudSignupView.vue +++ b/src/platform/cloud/onboarding/CloudSignupView.vue @@ -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) diff --git a/src/platform/cloud/onboarding/onboardingCloudRoutes.ts b/src/platform/cloud/onboarding/onboardingCloudRoutes.ts index e7d957b33d..31bb7b263e 100644 --- a/src/platform/cloud/onboarding/onboardingCloudRoutes.ts +++ b/src/platform/cloud/onboarding/onboardingCloudRoutes.ts @@ -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', diff --git a/src/platform/navigation/preservedQueryNamespaces.ts b/src/platform/navigation/preservedQueryNamespaces.ts index 8501f30519..87ccc4f8c8 100644 --- a/src/platform/navigation/preservedQueryNamespaces.ts +++ b/src/platform/navigation/preservedQueryNamespaces.ts @@ -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 diff --git a/src/router.ts b/src/router.ts index 7623786565..b653140d69 100644 --- a/src/router.ts +++ b/src/router.ts @@ -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' ]) diff --git a/vite.config.mts b/vite.config.mts index a95d5e4dc5..795c043807 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -214,6 +214,11 @@ export default defineConfig({ } }, + '/oauth': { + target: DEV_SERVER_COMFYUI_URL, + ...cloudProxyConfig + }, + '/ws': { target: DEV_SERVER_COMFYUI_URL, ws: true,