mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-21 21:09:00 +00:00
Compare commits
11 Commits
feat/pylon
...
kishore/oa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8035cd936d | ||
|
|
4718c565b2 | ||
|
|
25b6c3b2e0 | ||
|
|
7319121658 | ||
|
|
19569a8ff5 | ||
|
|
f318596ffa | ||
|
|
5935a1d121 | ||
|
|
1935bb4d5e | ||
|
|
887123a62f | ||
|
|
0767334ca1 | ||
|
|
36fa2cf43d |
@@ -2133,6 +2133,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.",
|
||||
"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.",
|
||||
"sessionError": "Failed to establish session. Please try again.",
|
||||
"sessionErrorToastSummary": "Couldn't continue OAuth sign-in"
|
||||
},
|
||||
"scopes": {
|
||||
"mcp:tools:read": {
|
||||
"label": "View available workflow tools"
|
||||
},
|
||||
"mcp:tools:call": {
|
||||
"label": "Run workflows on your behalf"
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"personal": "Personal",
|
||||
"owner": "Owner",
|
||||
"member": "Member"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"apiKey": {
|
||||
"title": "API Key",
|
||||
|
||||
91
src/platform/auth/session/useSessionCookie.test.ts
Normal file
91
src/platform/auth/session/useSessionCookie.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockGetIdToken = vi.fn()
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Restore the global fetch so a leaked mock doesn't bleed into later
|
||||
// tests that depend on real fetch semantics.
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
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,36 @@ 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: unknown = await response.json().catch(() => null)
|
||||
const message = (errorData as { message?: unknown } | null)?.message
|
||||
return typeof message === 'string' ? 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 +78,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,12 +91,23 @@ 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> => {
|
||||
if (!isCloud) return
|
||||
clearOAuthRequestId()
|
||||
|
||||
try {
|
||||
const response = await fetch(api.apiURL('/auth/session'), {
|
||||
@@ -82,10 +116,9 @@ export const useSessionCookie = () => {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
console.warn(
|
||||
'Failed to delete session cookie:',
|
||||
errorData.message || response.statusText
|
||||
await readSessionError(response)
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -95,6 +128,7 @@ export const useSessionCookie = () => {
|
||||
|
||||
return {
|
||||
createSession,
|
||||
createSessionOrThrow,
|
||||
deleteSession
|
||||
}
|
||||
}
|
||||
|
||||
112
src/platform/cloud/oauth/OAuthConsentView.stories.ts
Normal file
112
src/platform/cloud/oauth/OAuthConsentView.stories.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
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>
|
||||
|
||||
export const TwoWorkspaces: Story = {
|
||||
args: { initialChallenge: baseChallenge }
|
||||
}
|
||||
|
||||
export const SingleWorkspace: Story = {
|
||||
args: {
|
||||
initialChallenge: {
|
||||
...baseChallenge,
|
||||
workspaces: [baseChallenge.workspaces[0]]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const UnknownScope: Story = {
|
||||
args: {
|
||||
initialChallenge: {
|
||||
...baseChallenge,
|
||||
scopes: ['mcp:tools:read', 'mcp:tools:call', 'mcp:billing:read']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ClaudeDesktop: Story = {
|
||||
args: {
|
||||
initialChallenge: {
|
||||
...baseChallenge,
|
||||
client_display_name: 'Claude Desktop'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const WebClient: Story = {
|
||||
args: {
|
||||
initialChallenge: {
|
||||
...baseChallenge,
|
||||
client_display_name: 'Comfy Studio',
|
||||
client_application_type: 'web',
|
||||
redirect_uri: 'https://studio.example.com/oauth/cb'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const LegacyClientNoBadge: Story = {
|
||||
args: {
|
||||
// Pre-DCR seeded clients have no application_type — UI should hide
|
||||
// the badge entirely rather than guess.
|
||||
initialChallenge: {
|
||||
...baseChallenge,
|
||||
client_application_type: undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
247
src/platform/cloud/oauth/OAuthConsentView.test.ts
Normal file
247
src/platform/cloud/oauth/OAuthConsentView.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, 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 * as oauthApi from '@/platform/cloud/oauth/oauthApi'
|
||||
import type { OAuthConsentChallenge } from '@/platform/cloud/oauth/oauthApi'
|
||||
|
||||
const submitOAuthConsentDecision = vi.fn()
|
||||
|
||||
vi.mock('@/platform/cloud/oauth/oauthApi', async () => {
|
||||
const actual = await vi.importActual<typeof oauthApi>(
|
||||
'@/platform/cloud/oauth/oauthApi'
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
submitOAuthConsentDecision: (
|
||||
...args: Parameters<typeof actual.submitOAuthConsentDecision>
|
||||
) => submitOAuthConsentDecision(...args)
|
||||
}
|
||||
})
|
||||
|
||||
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.',
|
||||
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',
|
||||
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> = {}) =>
|
||||
render(OAuthConsentView, {
|
||||
global: { plugins: [i18n] },
|
||||
props: {
|
||||
initialChallenge: { ...challenge, ...overrides }
|
||||
}
|
||||
})
|
||||
|
||||
describe('OAuthConsentView', () => {
|
||||
beforeEach(() => {
|
||||
submitOAuthConsentDecision.mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
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: undefined })
|
||||
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()
|
||||
renderConsent({ workspaces: [challenge.workspaces[0]] })
|
||||
|
||||
// Single-workspace path: Allow is enabled and submission carries the
|
||||
// sole workspace_id.
|
||||
await user.click(screen.getByRole('button', { name: 'Continue' }))
|
||||
|
||||
expect(submitOAuthConsentDecision).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()
|
||||
renderConsent({ workspaces: [challenge.workspaces[0]] })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
expect(submitOAuthConsentDecision).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
decision: 'deny',
|
||||
workspaceId: 'personal-workspace'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('disables both buttons when no workspaces are available', () => {
|
||||
renderConsent({ workspaces: [] })
|
||||
expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('maps OAuthApiError(400) to the expired-request message', async () => {
|
||||
submitOAuthConsentDecision.mockRejectedValue(
|
||||
new OAuthApiError('expired', 400)
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderConsent({ workspaces: [challenge.workspaces[0]] })
|
||||
|
||||
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 () => {
|
||||
submitOAuthConsentDecision.mockRejectedValue(
|
||||
new OAuthApiError('scope broadening', 403)
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderConsent({ workspaces: [challenge.workspaces[0]] })
|
||||
|
||||
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 () => {
|
||||
submitOAuthConsentDecision.mockRejectedValue(
|
||||
new OAuthApiError('disabled', 404)
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderConsent({ workspaces: [challenge.workspaces[0]] })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Continue' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("This feature isn't available right now.")
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
325
src/platform/cloud/oauth/OAuthConsentView.vue
Normal file
325
src/platform/cloud/oauth/OAuthConsentView.vue
Normal file
@@ -0,0 +1,325 @@
|
||||
<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-secondary-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="icon-[lucide--key] size-5 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>
|
||||
<RadioGroupRoot
|
||||
v-else
|
||||
v-model="selectedWorkspaceId"
|
||||
: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"
|
||||
>
|
||||
<RadioGroupItem
|
||||
v-for="workspace in challenge.workspaces"
|
||||
:key="workspace.id"
|
||||
:value="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'
|
||||
)
|
||||
"
|
||||
>
|
||||
<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="icon-[lucide--check] size-4 shrink-0 text-base-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</RadioGroupItem>
|
||||
</RadioGroupRoot>
|
||||
<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="icon-[lucide--check] size-4 shrink-0 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="submitting === 'allow'"
|
||||
:disabled="isSubmitting || !selectedWorkspaceIsValid"
|
||||
@click="submit('allow')"
|
||||
>
|
||||
{{ t('oauth.consent.allow') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
:loading="submitting === 'deny'"
|
||||
:disabled="isSubmitting || challenge.workspaces.length === 0"
|
||||
@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 { cn } from '@comfyorg/tailwind-utils'
|
||||
import { RadioGroupItem, RadioGroupRoot } from 'reka-ui'
|
||||
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,
|
||||
OAuthWorkspace
|
||||
} from '@/platform/cloud/oauth/oauthApi'
|
||||
import {
|
||||
clearOAuthRequestId,
|
||||
getOAuthRequestId
|
||||
} from '@/platform/cloud/oauth/oauthState'
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
|
||||
const { initialChallenge } = defineProps<{
|
||||
initialChallenge?: OAuthConsentChallenge
|
||||
}>()
|
||||
|
||||
const { t, te } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
function getDefaultWorkspaceId(
|
||||
source: OAuthConsentChallenge | undefined
|
||||
): string | undefined {
|
||||
return source?.workspaces.length === 1 ? source.workspaces[0].id : undefined
|
||||
}
|
||||
|
||||
const challenge = ref<OAuthConsentChallenge | null>(initialChallenge ?? null)
|
||||
const selectedWorkspaceId = ref<string | undefined>(
|
||||
getDefaultWorkspaceId(initialChallenge)
|
||||
)
|
||||
const errorMessage = ref('')
|
||||
const submitting = ref<'allow' | 'deny' | null>(null)
|
||||
const isSubmitting = computed(() => submitting.value !== 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: 'icon-[lucide--monitor] size-3'
|
||||
}
|
||||
}
|
||||
if (appType === 'web') {
|
||||
return {
|
||||
label: t('oauth.consent.appTypeWeb'),
|
||||
icon: 'icon-[lucide--globe] size-3'
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const selectedWorkspaceIsValid = computed(() =>
|
||||
Boolean(
|
||||
selectedWorkspaceId.value &&
|
||||
challenge.value?.workspaces.some(
|
||||
(workspace) => workspace.id === selectedWorkspaceId.value
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
function scopeLabel(scope: string): string {
|
||||
const key = `oauth.scopes.${scope}.label`
|
||||
return te(key) ? t(key) : scope
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if (workspace.type === 'personal') return t('oauth.workspace.personal')
|
||||
return workspace.role === 'owner'
|
||||
? t('oauth.workspace.owner')
|
||||
: t('oauth.workspace.member')
|
||||
}
|
||||
|
||||
function requestIdFromRoute(): string | null {
|
||||
return typeof route.query.oauth_request_id === 'string'
|
||||
? route.query.oauth_request_id
|
||||
: getOAuthRequestId()
|
||||
}
|
||||
|
||||
async function loadChallenge() {
|
||||
const oauthRequestId = requestIdFromRoute()
|
||||
if (!oauthRequestId) {
|
||||
errorMessage.value = t('oauth.consent.missingRequest')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const next = await fetchOAuthConsentChallenge(oauthRequestId)
|
||||
challenge.value = next
|
||||
selectedWorkspaceId.value = getDefaultWorkspaceId(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
|
||||
// Cloud requires workspace_id on both allow and deny. A deny with no
|
||||
// workspaces is disabled in the template, so a workspace is guaranteed.
|
||||
const workspaceId =
|
||||
selectedWorkspaceId.value ?? challenge.value.workspaces[0]?.id
|
||||
if (!workspaceId) return
|
||||
|
||||
errorMessage.value = ''
|
||||
submitting.value = decision
|
||||
try {
|
||||
await submitOAuthConsentDecision({
|
||||
oauthRequestId: challenge.value.oauth_request_id,
|
||||
csrfToken: challenge.value.csrf_token,
|
||||
decision,
|
||||
workspaceId
|
||||
})
|
||||
clearOAuthRequestId()
|
||||
} catch (error) {
|
||||
errorMessage.value = messageForError(error)
|
||||
} finally {
|
||||
submitting.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!initialChallenge) {
|
||||
void loadChallenge()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
260
src/platform/cloud/oauth/oauthApi.test.ts
Normal file
260
src/platform/cloud/oauth/oauthApi.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
OAuthApiError,
|
||||
fetchOAuthConsentChallenge,
|
||||
submitOAuthConsentDecision
|
||||
} from '@/platform/cloud/oauth/oauthApi'
|
||||
import type { OAuthConsentChallenge } from '@/platform/cloud/oauth/oauthApi'
|
||||
|
||||
const validChallenge: OAuthConsentChallenge = {
|
||||
oauth_request_id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
csrf_token: 'csrf-token',
|
||||
client_display_name: 'Cursor',
|
||||
scopes: ['mcp:tools:read'],
|
||||
workspaces: [
|
||||
{
|
||||
id: 'personal-workspace',
|
||||
name: 'Kishore',
|
||||
type: 'personal',
|
||||
role: 'owner'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const okResponse = (body: unknown) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
||||
const errorResponse = (status: number, message: string) =>
|
||||
new Response(JSON.stringify({ message }), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
||||
describe('fetchOAuthConsentChallenge', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns the parsed challenge on 200', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(okResponse(validChallenge))
|
||||
|
||||
const result = await fetchOAuthConsentChallenge(
|
||||
validChallenge.oauth_request_id
|
||||
)
|
||||
|
||||
expect(result).toEqual(validChallenge)
|
||||
})
|
||||
|
||||
it('URL-encodes the oauth_request_id', async () => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValue(okResponse(validChallenge))
|
||||
|
||||
// Reserved characters get percent-encoded (defense-in-depth — valid UUIDs
|
||||
// never contain these chars, but the call should be safe regardless).
|
||||
await fetchOAuthConsentChallenge('id with spaces&injected=evil')
|
||||
|
||||
const url = fetchSpy.mock.calls[0]?.[0] as string
|
||||
expect(url).toContain(
|
||||
'oauth_request_id=id%20with%20spaces%26injected%3Devil'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws OAuthApiError with status on non-2xx', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
errorResponse(400, 'expired')
|
||||
)
|
||||
|
||||
await expect(fetchOAuthConsentChallenge('abc')).rejects.toMatchObject({
|
||||
name: 'OAuthApiError',
|
||||
status: 400
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects when scopes are not strings', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
okResponse({ ...validChallenge, scopes: [123, 'mcp:tools:read'] })
|
||||
)
|
||||
|
||||
await expect(fetchOAuthConsentChallenge('abc')).rejects.toThrow(
|
||||
'OAuth consent challenge is invalid'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects when a workspace is missing required fields', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
okResponse({
|
||||
...validChallenge,
|
||||
workspaces: [{ id: 'x', name: 'y', type: 'personal' }]
|
||||
})
|
||||
)
|
||||
|
||||
await expect(fetchOAuthConsentChallenge('abc')).rejects.toThrow(
|
||||
'OAuth consent challenge is invalid'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects when workspace.type is not personal or team', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
okResponse({
|
||||
...validChallenge,
|
||||
workspaces: [{ id: 'x', name: 'y', type: 'enterprise', role: 'owner' }]
|
||||
})
|
||||
)
|
||||
|
||||
await expect(fetchOAuthConsentChallenge('abc')).rejects.toThrow(
|
||||
'OAuth consent challenge is invalid'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects when workspace.role is not owner or member', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
okResponse({
|
||||
...validChallenge,
|
||||
workspaces: [{ id: 'x', name: 'y', type: 'team', role: 'admin' }]
|
||||
})
|
||||
)
|
||||
|
||||
await expect(fetchOAuthConsentChallenge('abc')).rejects.toThrow(
|
||||
'OAuth consent challenge is invalid'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects when top-level fields are missing', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
okResponse({ ...validChallenge, csrf_token: undefined })
|
||||
)
|
||||
|
||||
await expect(fetchOAuthConsentChallenge('abc')).rejects.toThrow(
|
||||
'OAuth consent challenge is invalid'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects null body', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(okResponse(null))
|
||||
|
||||
await expect(fetchOAuthConsentChallenge('abc')).rejects.toThrow(
|
||||
'OAuth consent challenge is invalid'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('submitOAuthConsentDecision', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('navigates to the redirect_url returned by cloud on success', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
okResponse({ redirect_url: 'http://127.0.0.1:50632/cb?code=xyz' })
|
||||
)
|
||||
// jsdom location is not writable directly; replace href via spy.
|
||||
const originalLocation = globalThis.location
|
||||
const hrefSetter = vi.fn()
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
configurable: true,
|
||||
value: new Proxy(originalLocation, {
|
||||
set(_target, prop, value) {
|
||||
if (prop === 'href') {
|
||||
hrefSetter(value)
|
||||
return true
|
||||
}
|
||||
return Reflect.set(originalLocation, prop, value)
|
||||
},
|
||||
get(_target, prop) {
|
||||
return Reflect.get(originalLocation, prop)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
await submitOAuthConsentDecision({
|
||||
oauthRequestId: validChallenge.oauth_request_id,
|
||||
csrfToken: validChallenge.csrf_token,
|
||||
decision: 'allow',
|
||||
workspaceId: 'personal-workspace'
|
||||
})
|
||||
|
||||
expect(hrefSetter).toHaveBeenCalledWith(
|
||||
'http://127.0.0.1:50632/cb?code=xyz'
|
||||
)
|
||||
} finally {
|
||||
// Restore unconditionally so an assertion failure doesn't leak the
|
||||
// Proxy'd location into later tests.
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('throws OAuthApiError on non-2xx', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
errorResponse(403, 'scope broadening')
|
||||
)
|
||||
|
||||
await expect(
|
||||
submitOAuthConsentDecision({
|
||||
oauthRequestId: validChallenge.oauth_request_id,
|
||||
csrfToken: validChallenge.csrf_token,
|
||||
decision: 'allow',
|
||||
workspaceId: 'personal-workspace'
|
||||
})
|
||||
).rejects.toBeInstanceOf(OAuthApiError)
|
||||
})
|
||||
|
||||
it('throws when redirect_url is missing from a successful response', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(okResponse({}))
|
||||
|
||||
await expect(
|
||||
submitOAuthConsentDecision({
|
||||
oauthRequestId: validChallenge.oauth_request_id,
|
||||
csrfToken: validChallenge.csrf_token,
|
||||
decision: 'allow',
|
||||
workspaceId: 'personal-workspace'
|
||||
})
|
||||
).rejects.toThrow('redirect_url')
|
||||
})
|
||||
|
||||
it('rejects an unsafe redirect_url scheme', async () => {
|
||||
// Defense in depth: even though the cloud backend is trusted, never
|
||||
// hand the browser off to a non-http(s) URL.
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
okResponse({ redirect_url: 'javascript:alert(1)' })
|
||||
)
|
||||
|
||||
await expect(
|
||||
submitOAuthConsentDecision({
|
||||
oauthRequestId: validChallenge.oauth_request_id,
|
||||
csrfToken: validChallenge.csrf_token,
|
||||
decision: 'allow',
|
||||
workspaceId: 'personal-workspace'
|
||||
})
|
||||
).rejects.toThrow('unsafe scheme')
|
||||
})
|
||||
|
||||
it('sends the expected JSON body', async () => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValue(okResponse({ redirect_url: 'http://x.test/' }))
|
||||
|
||||
await submitOAuthConsentDecision({
|
||||
oauthRequestId: validChallenge.oauth_request_id,
|
||||
csrfToken: validChallenge.csrf_token,
|
||||
decision: 'deny',
|
||||
workspaceId: 'personal-workspace'
|
||||
})
|
||||
|
||||
const init = fetchSpy.mock.calls[0]?.[1] as RequestInit
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
oauth_request_id: validChallenge.oauth_request_id,
|
||||
csrf_token: validChallenge.csrf_token,
|
||||
decision: 'deny',
|
||||
workspace_id: 'personal-workspace'
|
||||
})
|
||||
})
|
||||
})
|
||||
156
src/platform/cloud/oauth/oauthApi.ts
Normal file
156
src/platform/cloud/oauth/oauthApi.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
// All OAuth calls are relative-URL (same-origin) on purpose. useSessionCookie
|
||||
// POSTs /api/auth/session through the Vite dev-server proxy (or the production
|
||||
// same-host ingress), 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). The Vite proxy / production ingress is
|
||||
// the single point of routing.
|
||||
|
||||
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)
|
||||
* or "web" (HTTPS-hosted). Absent 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>
|
||||
|
||||
export class OAuthApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'OAuthApiError'
|
||||
}
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response): Promise<string> {
|
||||
const body: unknown = await response.json().catch(() => null)
|
||||
const message = (body as { message?: unknown } | null)?.message
|
||||
return typeof message === 'string' ? 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) ||
|
||||
!challenge.scopes.every((scope) => typeof scope === 'string') ||
|
||||
!Array.isArray(challenge.workspaces) ||
|
||||
!challenge.workspaces.every(isValidWorkspace)
|
||||
) {
|
||||
throw new Error('OAuth consent challenge is invalid')
|
||||
}
|
||||
}
|
||||
|
||||
function isValidWorkspace(value: unknown): value is OAuthWorkspace {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
const workspace = value as Partial<OAuthWorkspace>
|
||||
return (
|
||||
typeof workspace.id === 'string' &&
|
||||
typeof workspace.name === 'string' &&
|
||||
(workspace.type === 'personal' || workspace.type === 'team') &&
|
||||
(workspace.role === 'owner' || workspace.role === 'member')
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchOAuthConsentChallenge(
|
||||
oauthRequestId: string
|
||||
): Promise<OAuthConsentChallenge> {
|
||||
const response = await fetch(
|
||||
`/oauth/authorize?oauth_request_id=${encodeURIComponent(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('/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: unknown = await response.json()
|
||||
const redirectUrl = (body as { redirect_url?: unknown } | null)?.redirect_url
|
||||
if (typeof redirectUrl !== 'string') {
|
||||
throw new Error('OAuth consent response did not include redirect_url')
|
||||
}
|
||||
|
||||
// Defense in depth: even though the cloud backend is trusted, never hand
|
||||
// the browser off to a non-http(s) scheme. javascript:/data: URLs would
|
||||
// execute in our origin.
|
||||
const target = new URL(redirectUrl, globalThis.location.origin)
|
||||
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
|
||||
throw new Error('OAuth consent redirect_url has an unsafe scheme')
|
||||
}
|
||||
|
||||
globalThis.location.href = redirectUrl
|
||||
}
|
||||
91
src/platform/cloud/oauth/oauthState.test.ts
Normal file
91
src/platform/cloud/oauth/oauthState.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
captureOAuthRequestId,
|
||||
clearOAuthRequestId,
|
||||
getOAuthRequestId
|
||||
} 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(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('preserves a stored id when the query has no oauth_request_id key', () => {
|
||||
// The router guard runs on every navigation, including the OAuth
|
||||
// return-trip from a social-login provider (Google / GitHub) which
|
||||
// arrives at /login with `code` + `state` but no oauth_request_id.
|
||||
// The previously-captured id MUST survive that hop.
|
||||
sessionStorage.setItem(
|
||||
'Comfy.OAuthRequestId',
|
||||
'550e8400-e29b-41d4-a716-446655440000'
|
||||
)
|
||||
|
||||
captureOAuthRequestId({ code: 'oauth-provider-code', state: 'xyz' })
|
||||
|
||||
expect(getOAuthRequestId()).toBe('550e8400-e29b-41d4-a716-446655440000')
|
||||
})
|
||||
|
||||
it('clears a stored id when the query has an invalid oauth_request_id', () => {
|
||||
// Stale deep-link or probing — drop the stored value rather than let
|
||||
// it steer later flows into an expired consent request.
|
||||
sessionStorage.setItem(
|
||||
'Comfy.OAuthRequestId',
|
||||
'550e8400-e29b-41d4-a716-446655440000'
|
||||
)
|
||||
|
||||
captureOAuthRequestId({ oauth_request_id: 'not-a-uuid' })
|
||||
|
||||
expect(getOAuthRequestId()).toBeNull()
|
||||
expect(sessionStorage.getItem('Comfy.OAuthRequestId')).toBeNull()
|
||||
})
|
||||
|
||||
it('clears a stored id when the query has a repeated oauth_request_id', () => {
|
||||
sessionStorage.setItem(
|
||||
'Comfy.OAuthRequestId',
|
||||
'550e8400-e29b-41d4-a716-446655440000'
|
||||
)
|
||||
|
||||
captureOAuthRequestId({ oauth_request_id: ['a', 'b'] })
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
54
src/platform/cloud/oauth/oauthState.ts
Normal file
54
src/platform/cloud/oauth/oauthState.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
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 {
|
||||
// The router guard calls this on every navigation. We can't unconditionally
|
||||
// clear on absence — the OAuth return-trip from a social-login provider
|
||||
// (Google / GitHub) arrives at /login with `code` + `state` in the query
|
||||
// but no `oauth_request_id`, and we need the previously-captured value to
|
||||
// survive that hop.
|
||||
//
|
||||
// We DO clear on an explicitly invalid value (present but malformed): that
|
||||
// shape is either a stale deep-link or probing, and a stale Comfy.OAuthRequestId
|
||||
// contaminating later flows is worse than dropping the bad input.
|
||||
const raw = query.oauth_request_id
|
||||
const value = readQueryString(raw)
|
||||
if (!value) {
|
||||
if (raw !== undefined) {
|
||||
// Present but non-string (e.g. repeated `?oauth_request_id=a&oauth_request_id=b`).
|
||||
sessionStorage.removeItem(OAUTH_REQUEST_ID_STORAGE_KEY)
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (!isOAuthRequestId(value)) {
|
||||
sessionStorage.removeItem(OAUTH_REQUEST_ID_STORAGE_KEY)
|
||||
return null
|
||||
}
|
||||
|
||||
sessionStorage.setItem(OAUTH_REQUEST_ID_STORAGE_KEY, value)
|
||||
return value
|
||||
}
|
||||
|
||||
export function getOAuthRequestId(): string | null {
|
||||
return readStoredOAuthRequestId()
|
||||
}
|
||||
|
||||
export function clearOAuthRequestId(): void {
|
||||
sessionStorage.removeItem(OAUTH_REQUEST_ID_STORAGE_KEY)
|
||||
}
|
||||
118
src/platform/cloud/oauth/useOAuthPostLoginRedirect.test.ts
Normal file
118
src/platform/cloud/oauth/useOAuthPostLoginRedirect.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { useOAuthPostLoginRedirect } from '@/platform/cloud/oauth/useOAuthPostLoginRedirect'
|
||||
|
||||
const VALID_REQUEST_ID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
|
||||
const routerPush = vi.fn().mockResolvedValue(undefined)
|
||||
const createSessionOrThrow = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: routerPush })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/auth/session/useSessionCookie', () => ({
|
||||
useSessionCookie: () => ({ createSessionOrThrow })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
function mountRedirect() {
|
||||
let api: ReturnType<typeof useOAuthPostLoginRedirect> | undefined
|
||||
|
||||
const Child = defineComponent({
|
||||
setup() {
|
||||
api = useOAuthPostLoginRedirect()
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
|
||||
const host = document.createElement('div')
|
||||
const app = createApp(defineComponent({ setup: () => () => h(Child) }))
|
||||
app.use(i18n)
|
||||
app.mount(host)
|
||||
|
||||
if (!api) throw new Error('useOAuthPostLoginRedirect was not initialized')
|
||||
return { api, unmount: () => app.unmount() }
|
||||
}
|
||||
|
||||
describe('useOAuthPostLoginRedirect', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
routerPush.mockClear()
|
||||
createSessionOrThrow.mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('returns no-oauth when neither query nor sessionStorage holds a request id', async () => {
|
||||
const { api } = mountRedirect()
|
||||
|
||||
const result = await api.resumeOAuthIfNeeded({})
|
||||
|
||||
expect(result).toEqual({ kind: 'no-oauth' })
|
||||
expect(createSessionOrThrow).not.toHaveBeenCalled()
|
||||
expect(routerPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('establishes session and navigates to consent when oauth_request_id is in the query', async () => {
|
||||
const { api } = mountRedirect()
|
||||
|
||||
const result = await api.resumeOAuthIfNeeded({
|
||||
oauth_request_id: VALID_REQUEST_ID
|
||||
})
|
||||
|
||||
expect(createSessionOrThrow).toHaveBeenCalledOnce()
|
||||
expect(routerPush).toHaveBeenCalledWith({
|
||||
name: 'cloud-oauth-consent',
|
||||
query: { oauth_request_id: VALID_REQUEST_ID }
|
||||
})
|
||||
expect(result).toEqual({ kind: 'resumed' })
|
||||
})
|
||||
|
||||
it('resumes using a stashed sessionStorage id when the query is empty (multi-step flows)', async () => {
|
||||
sessionStorage.setItem('Comfy.OAuthRequestId', VALID_REQUEST_ID)
|
||||
const { api } = mountRedirect()
|
||||
|
||||
const result = await api.resumeOAuthIfNeeded({})
|
||||
|
||||
expect(result).toEqual({ kind: 'resumed' })
|
||||
expect(routerPush).toHaveBeenCalledWith({
|
||||
name: 'cloud-oauth-consent',
|
||||
query: { oauth_request_id: VALID_REQUEST_ID }
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an error with the thrown message when session creation fails', async () => {
|
||||
createSessionOrThrow.mockRejectedValue(new Error('Unauthorized'))
|
||||
const { api } = mountRedirect()
|
||||
|
||||
const result = await api.resumeOAuthIfNeeded({
|
||||
oauth_request_id: VALID_REQUEST_ID
|
||||
})
|
||||
|
||||
expect(result).toEqual({ kind: 'error', message: 'Unauthorized' })
|
||||
expect(routerPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to the i18n key when session creation rejects with a non-Error value', async () => {
|
||||
createSessionOrThrow.mockRejectedValue('boom')
|
||||
const { api } = mountRedirect()
|
||||
|
||||
const result = await api.resumeOAuthIfNeeded({
|
||||
oauth_request_id: VALID_REQUEST_ID
|
||||
})
|
||||
|
||||
// Empty messages → useI18n returns the key itself, which is what we
|
||||
// assert on (per docs/testing/vitest-patterns.md).
|
||||
expect(result).toEqual({
|
||||
kind: 'error',
|
||||
message: 'oauth.consent.sessionError'
|
||||
})
|
||||
expect(routerPush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
53
src/platform/cloud/oauth/useOAuthPostLoginRedirect.ts
Normal file
53
src/platform/cloud/oauth/useOAuthPostLoginRedirect.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useSessionCookie } from '@/platform/auth/session/useSessionCookie'
|
||||
import {
|
||||
captureOAuthRequestId,
|
||||
getOAuthRequestId
|
||||
} from '@/platform/cloud/oauth/oauthState'
|
||||
|
||||
type OAuthResumeResult =
|
||||
| { kind: 'no-oauth' }
|
||||
| { kind: 'resumed' }
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
/**
|
||||
* Post-login OAuth resume. If the current login flow originated from an OAuth
|
||||
* authorize request, establishes the Cloud session cookie and navigates to the
|
||||
* consent route. Used by both `CloudLoginView` and `CloudSignupView`.
|
||||
*/
|
||||
export function useOAuthPostLoginRedirect() {
|
||||
const router = useRouter()
|
||||
const sessionCookie = useSessionCookie()
|
||||
const { t } = useI18n()
|
||||
|
||||
async function resumeOAuthIfNeeded(
|
||||
query: LocationQuery
|
||||
): Promise<OAuthResumeResult> {
|
||||
captureOAuthRequestId(query)
|
||||
const oauthRequestId = getOAuthRequestId()
|
||||
if (!oauthRequestId) return { kind: 'no-oauth' }
|
||||
|
||||
try {
|
||||
await sessionCookie.createSessionOrThrow()
|
||||
} catch (error) {
|
||||
return {
|
||||
kind: 'error',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('oauth.consent.sessionError')
|
||||
}
|
||||
}
|
||||
|
||||
await router.push({
|
||||
name: 'cloud-oauth-consent',
|
||||
query: { oauth_request_id: oauthRequestId }
|
||||
})
|
||||
return { kind: 'resumed' }
|
||||
}
|
||||
|
||||
return { resumeOAuthIfNeeded }
|
||||
}
|
||||
@@ -116,6 +116,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useOAuthPostLoginRedirect } from '@/platform/cloud/oauth/useOAuthPostLoginRedirect'
|
||||
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 +128,7 @@ const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authActions = useAuthActions()
|
||||
const { resumeOAuthIfNeeded } = useOAuthPostLoginRedirect()
|
||||
const isSecureContext = globalThis.isSecureContext
|
||||
const authError = ref('')
|
||||
const toastStore = useToastStore()
|
||||
@@ -153,6 +155,21 @@ const onSuccess = async () => {
|
||||
life: 2000
|
||||
})
|
||||
|
||||
const oauthResume = await resumeOAuthIfNeeded(route.query)
|
||||
if (oauthResume.kind === 'error') {
|
||||
// authError renders only in email-form mode; surface the failure via
|
||||
// a toast so social-login users (Google / GitHub) can see it too.
|
||||
authError.value = oauthResume.message
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('oauth.consent.sessionErrorToastSummary'),
|
||||
detail: oauthResume.message,
|
||||
life: 4000
|
||||
})
|
||||
return
|
||||
}
|
||||
if (oauthResume.kind === 'resumed') return
|
||||
|
||||
const previousFullPath = getSafePreviousFullPath(route.query)
|
||||
if (previousFullPath) {
|
||||
await router.replace(previousFullPath)
|
||||
|
||||
@@ -141,6 +141,7 @@ 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 { useOAuthPostLoginRedirect } from '@/platform/cloud/oauth/useOAuthPostLoginRedirect'
|
||||
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
|
||||
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
@@ -154,6 +155,7 @@ const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authActions = useAuthActions()
|
||||
const { resumeOAuthIfNeeded } = useOAuthPostLoginRedirect()
|
||||
const isSecureContext = globalThis.isSecureContext
|
||||
const authError = ref('')
|
||||
const userIsInChina = ref(false)
|
||||
@@ -179,6 +181,21 @@ const onSuccess = async () => {
|
||||
life: 2000
|
||||
})
|
||||
|
||||
const oauthResume = await resumeOAuthIfNeeded(route.query)
|
||||
if (oauthResume.kind === 'error') {
|
||||
// authError renders only in email-form mode; surface the failure via
|
||||
// a toast so social-login users (Google / GitHub) can see it too.
|
||||
authError.value = oauthResume.message
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('oauth.consent.sessionErrorToastSummary'),
|
||||
detail: oauthResume.message,
|
||||
life: 4000
|
||||
})
|
||||
return
|
||||
}
|
||||
if (oauthResume.kind === 'resumed') return
|
||||
|
||||
const previousFullPath = getSafePreviousFullPath(route.query)
|
||||
if (previousFullPath) {
|
||||
await router.replace(previousFullPath)
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
import { getOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
|
||||
|
||||
// `oauth_request_id` capture lives in the global router.beforeEach guard
|
||||
// (src/router.ts), which runs before any per-route beforeEnter. Per-route
|
||||
// guards read it back via getOAuthRequestId().
|
||||
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',
|
||||
@@ -19,9 +34,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()
|
||||
@@ -39,7 +52,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
if (isLoggedIn.value) {
|
||||
return next({ name: 'cloud-user-check' })
|
||||
return next(oauthConsentRedirect())
|
||||
}
|
||||
}
|
||||
next()
|
||||
@@ -58,6 +71,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
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
|
||||
|
||||
@@ -214,6 +214,11 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
|
||||
'/oauth': {
|
||||
target: DEV_SERVER_COMFYUI_URL,
|
||||
...cloudProxyConfig
|
||||
},
|
||||
|
||||
'/ws': {
|
||||
target: DEV_SERVER_COMFYUI_URL,
|
||||
ws: true,
|
||||
|
||||
Reference in New Issue
Block a user