feat: add OAuth frontend consent flow

Implements the BE-638 frontend plumbing for MCP OAuth:

- Capture and preserve oauth_request_id across login/signup flows via
  sessionStorage + router query preservation.
- Establish the Cloud session cookie via POST /api/auth/session after
  Firebase auth success, before resuming OAuth.
- Consent route /cloud/oauth/consent fetches the JSON challenge and
  submits the user's decision back to cloud.
This commit is contained in:
kishore
2026-05-11 19:35:26 -07:00
parent d05ec230bf
commit 36fa2cf43d
15 changed files with 813 additions and 15 deletions

View File

@@ -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",

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,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<typeof OAuthConsentView> = {
title: 'Cloud/OAuth/Consent',
component: OAuthConsentView
}
export default meta
type Story = StoryObj<typeof meta>
export const AllMcpScopes: Story = {
args: {
initialChallenge: previewChallenge,
submitDecision: async () => {}
}
}

View File

@@ -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<OAuthConsentChallenge> = {},
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'
})
})
})

View File

@@ -0,0 +1,202 @@
<template>
<main class="mx-auto flex max-w-2xl flex-col gap-6 p-8">
<section v-if="challenge" class="flex flex-col gap-6">
<div class="flex flex-col gap-2">
<p class="m-0 text-sm text-muted">
{{ t('oauth.consent.resourceLabel') }}
</p>
<h1 class="m-0 text-2xl font-semibold">
{{ challenge.client_display_name }}
</h1>
<p v-if="challenge.resource_display_name" class="m-0 text-muted">
{{ challenge.resource_display_name }}
</p>
</div>
<section class="rounded-lg bg-(--p-content-background) p-4">
<h2 class="mt-0 text-base font-semibold">
{{ t('oauth.consent.scopesTitle') }}
</h2>
<ul class="mb-0 flex flex-col gap-2 pl-5">
<li v-for="scope in challenge.scopes" :key="scope">
{{ labelForScope(scope) }}
</li>
</ul>
</section>
<section class="rounded-lg bg-(--p-content-background) p-4">
<h2 class="mt-0 text-base font-semibold">
{{ t('oauth.consent.workspaceTitle') }}
</h2>
<p v-if="challenge.workspaces.length === 0" class="text-muted">
{{ t('oauth.consent.noWorkspaces') }}
</p>
<div v-else-if="challenge.workspaces.length === 1">
<WorkspaceSummary :workspace="challenge.workspaces[0]" />
</div>
<fieldset v-else class="m-0 flex flex-col gap-3 border-0 p-0">
<legend class="sr-only">
{{ t('oauth.consent.workspaceTitle') }}
</legend>
<label
v-for="workspace in challenge.workspaces"
:key="workspace.id"
class="flex cursor-pointer gap-3 rounded-md border border-solid border-muted p-3"
>
<input
v-model="selectedWorkspaceId"
type="radio"
name="workspace"
:value="workspace.id"
/>
<WorkspaceSummary :workspace />
</label>
</fieldset>
</section>
<p v-if="errorMessage" role="alert" class="m-0 text-red-500">
{{ errorMessage }}
</p>
<div class="flex gap-3">
<button
class="rounded-md bg-neutral-700 px-4 py-2 text-white disabled:opacity-50"
:disabled="isSubmitting || !canSubmit"
@click="submit('deny')"
>
{{ t('oauth.consent.deny') }}
</button>
<button
class="rounded-md bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
:disabled="isSubmitting || !canSubmit"
@click="submit('allow')"
>
{{ t('oauth.consent.allow') }}
</button>
</div>
</section>
<p v-else-if="errorMessage" role="alert" class="m-0 text-red-500">
{{ errorMessage }}
</p>
<p v-else class="m-0 text-muted">
{{ t('oauth.consent.loading') }}
</p>
</main>
</template>
<script setup lang="ts">
import { computed, h, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import {
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'
const { initialChallenge, submitDecision = submitOAuthConsentDecision } =
defineProps<{
initialChallenge?: OAuthConsentChallenge
submitDecision?: OAuthConsentDecision
}>()
const WorkspaceSummary = (props: { workspace: OAuthWorkspace }) =>
h('span', { class: 'flex flex-col gap-1' }, [
h('span', props.workspace.name),
h('span', { class: 'text-xs text-muted' }, [
props.workspace.type,
' · ',
props.workspace.role
])
])
const { t } = useI18n()
const route = useRoute()
const challenge = ref<OAuthConsentChallenge | null>(initialChallenge ?? null)
const selectedWorkspaceId = ref(
initialChallenge?.workspaces.length === 1
? initialChallenge.workspaces[0].id
: ''
)
const errorMessage = ref('')
const isSubmitting = ref(false)
const selectedWorkspaceIsValid = computed(() => {
return Boolean(
challenge.value?.workspaces.some(
(workspace) => workspace.id === selectedWorkspaceId.value
)
)
})
const canSubmit = computed(() => selectedWorkspaceIsValid.value)
function labelForScope(scope: string): string {
return scope
}
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 : ''
}
async function loadChallenge() {
const oauthRequestId = requestIdFromRoute()
if (!oauthRequestId) {
errorMessage.value = t('oauth.consent.missingRequest')
return
}
try {
const nextChallenge = await fetchOAuthConsentChallenge(oauthRequestId)
challenge.value = nextChallenge
initializeWorkspaceSelection(nextChallenge)
} catch (error) {
errorMessage.value =
error instanceof Error ? error.message : t('oauth.consent.genericError')
}
}
async function submit(decision: 'allow' | 'deny') {
if (!challenge.value || !selectedWorkspaceIsValid.value) return
errorMessage.value = ''
isSubmitting.value = true
try {
await submitDecision({
oauthRequestId: challenge.value.oauth_request_id,
csrfToken: challenge.value.csrf_token,
decision,
workspaceId: selectedWorkspaceId.value
})
clearOAuthRequestId()
} catch (error) {
errorMessage.value =
error instanceof Error ? error.message : t('oauth.consent.genericError')
} finally {
isSubmitting.value = false
}
}
onMounted(() => {
if (!initialChallenge) {
void loadChallenge()
}
})
</script>

View File

@@ -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<void>
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<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

@@ -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

@@ -214,6 +214,11 @@ export default defineConfig({
}
},
'/oauth': {
target: DEV_SERVER_COMFYUI_URL,
...cloudProxyConfig
},
'/ws': {
target: DEV_SERVER_COMFYUI_URL,
ws: true,