mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 06:35:10 +00:00
feat: polish OAuth consent UI and align with workspace switcher design
- Workspace picker is now an inline radio list (matches the cloud workspace switcher) instead of a dropdown — uses WorkspaceProfilePic avatars so OAuth consent feels native to the cloud app. - Cap list at max-h-72 with scrollbar-custom so 10+ workspaces stay discoverable. - Resume cloud calls are now relative (same-origin via Vite proxy or prod reverse proxy) — fixes the cross-origin cookie loss that would bounce the consent challenge back to login. - Map 400 / 403 / 404 cloud errors to user-facing messages (expired / scope_broadening / feature_unavailable). - Surface registered redirect_uri and RFC 7591 application_type so users can verify the loopback destination before granting.
This commit is contained in:
@@ -2131,15 +2131,39 @@
|
||||
},
|
||||
"oauth": {
|
||||
"consent": {
|
||||
"allow": "Allow",
|
||||
"deny": "Deny",
|
||||
"allow": "Continue",
|
||||
"deny": "Cancel",
|
||||
"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.",
|
||||
"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.",
|
||||
"resourceLabel": "Authorize this app",
|
||||
"scopesTitle": "Requested access",
|
||||
"workspaceTitle": "Choose workspace"
|
||||
"title": "{client} wants access",
|
||||
"subtitle": "Sign in to {resource} to continue",
|
||||
"resourceFallback": "this app",
|
||||
"workspaceLabel": "Workspace",
|
||||
"permissionsHeader": "Permissions",
|
||||
"workspaceHelp": "Permissions apply to this workspace only.",
|
||||
"learnMore": "Learn more",
|
||||
"redirectNotice": "You'll be redirected to",
|
||||
"appTypeNative": "Native app",
|
||||
"appTypeWeb": "Web app",
|
||||
"errorExpired": "This consent request has expired or has already been used. Please restart from the client app.",
|
||||
"errorScopeBroadening": "The previously approved permissions don't cover this request. You'll need to re-authorize with the new permissions.",
|
||||
"errorUnavailable": "This feature isn't available right now. Please contact support if the problem persists."
|
||||
},
|
||||
"scopes": {
|
||||
"mcp:tools:read": {
|
||||
"label": "View available workflow tools"
|
||||
},
|
||||
"mcp:tools:call": {
|
||||
"label": "Run workflows on your behalf"
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"personal": "Personal",
|
||||
"team": "Team",
|
||||
"owner": "Owner",
|
||||
"member": "Member"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
|
||||
@@ -3,12 +3,14 @@ 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 = {
|
||||
const baseChallenge: 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'],
|
||||
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',
|
||||
@@ -18,7 +20,7 @@ const previewChallenge: OAuthConsentChallenge = {
|
||||
},
|
||||
{
|
||||
id: 'team-workspace',
|
||||
name: 'Team Workspace',
|
||||
name: 'Comfy Team',
|
||||
type: 'team',
|
||||
role: 'member'
|
||||
}
|
||||
@@ -29,13 +31,87 @@ const meta: Meta<typeof OAuthConsentView> = {
|
||||
title: 'Cloud/OAuth/Consent',
|
||||
component: OAuthConsentView
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const AllMcpScopes: Story = {
|
||||
const noopSubmit = async () => {}
|
||||
|
||||
export const TwoWorkspaces: Story = {
|
||||
args: { initialChallenge: baseChallenge, submitDecision: noopSubmit }
|
||||
}
|
||||
|
||||
export const SingleWorkspace: Story = {
|
||||
args: {
|
||||
initialChallenge: previewChallenge,
|
||||
submitDecision: async () => {}
|
||||
initialChallenge: {
|
||||
...baseChallenge,
|
||||
workspaces: [baseChallenge.workspaces[0]]
|
||||
},
|
||||
submitDecision: noopSubmit
|
||||
}
|
||||
}
|
||||
|
||||
export const ManyWorkspaces: Story = {
|
||||
args: {
|
||||
initialChallenge: {
|
||||
...baseChallenge,
|
||||
workspaces: [
|
||||
baseChallenge.workspaces[0],
|
||||
baseChallenge.workspaces[1],
|
||||
{
|
||||
id: 'design-team',
|
||||
name: 'Design Studio',
|
||||
type: 'team',
|
||||
role: 'owner'
|
||||
},
|
||||
{
|
||||
id: 'agency-team',
|
||||
name: 'Agency Workspace',
|
||||
type: 'team',
|
||||
role: 'member'
|
||||
}
|
||||
]
|
||||
},
|
||||
submitDecision: noopSubmit
|
||||
}
|
||||
}
|
||||
|
||||
export const UnknownScope: Story = {
|
||||
args: {
|
||||
initialChallenge: {
|
||||
...baseChallenge,
|
||||
scopes: ['mcp:tools:read', 'mcp:tools:call', 'mcp:billing:read']
|
||||
},
|
||||
submitDecision: noopSubmit
|
||||
}
|
||||
}
|
||||
|
||||
export const ClaudeDesktop: Story = {
|
||||
args: {
|
||||
initialChallenge: {
|
||||
...baseChallenge,
|
||||
client_display_name: 'Claude Desktop'
|
||||
},
|
||||
submitDecision: noopSubmit
|
||||
}
|
||||
}
|
||||
|
||||
export const WebClient: Story = {
|
||||
args: {
|
||||
initialChallenge: {
|
||||
...baseChallenge,
|
||||
client_display_name: 'Comfy Studio',
|
||||
client_application_type: 'web',
|
||||
redirect_uri: 'https://studio.example.com/oauth/cb'
|
||||
},
|
||||
submitDecision: noopSubmit
|
||||
}
|
||||
}
|
||||
|
||||
export const LegacyClientNoBadge: Story = {
|
||||
args: {
|
||||
// Pre-DCR seeded clients have application_type="" — UI should hide
|
||||
// the badge entirely rather than guess.
|
||||
initialChallenge: { ...baseChallenge, client_application_type: '' },
|
||||
submitDecision: noopSubmit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import OAuthConsentView from '@/platform/cloud/oauth/OAuthConsentView.vue'
|
||||
import { OAuthApiError } from '@/platform/cloud/oauth/oauthApi'
|
||||
import type { OAuthConsentChallenge } from '@/platform/cloud/oauth/oauthApi'
|
||||
|
||||
const i18n = createI18n({
|
||||
@@ -11,17 +12,45 @@ const i18n = createI18n({
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
singleSelectDropdown: 'Select an option'
|
||||
},
|
||||
oauth: {
|
||||
consent: {
|
||||
allow: 'Allow',
|
||||
deny: 'Deny',
|
||||
allow: 'Continue',
|
||||
deny: 'Cancel',
|
||||
genericError: 'OAuth request failed.',
|
||||
loading: 'Loading OAuth request...',
|
||||
missingRequest: 'This OAuth request is missing.',
|
||||
loading: 'Loading authorization request…',
|
||||
missingRequest: 'This authorization request is missing.',
|
||||
noWorkspaces: 'No eligible workspaces are available.',
|
||||
resourceLabel: 'Authorize this app',
|
||||
scopesTitle: 'Requested access',
|
||||
workspaceTitle: 'Choose workspace'
|
||||
title: '{client} wants access',
|
||||
subtitle: 'Sign in to {resource} to continue',
|
||||
workspaceLabel: 'Workspace',
|
||||
permissionsHeader: 'Permissions',
|
||||
workspaceHelp: 'Permissions apply to this workspace only.',
|
||||
learnMore: 'Learn more',
|
||||
redirectNotice: "You'll be redirected to",
|
||||
appTypeNative: 'Native app',
|
||||
appTypeWeb: 'Web app',
|
||||
errorExpired:
|
||||
'This consent request has expired or has already been used.',
|
||||
errorScopeBroadening:
|
||||
"The previously approved permissions don't cover this request.",
|
||||
errorUnavailable: "This feature isn't available right now."
|
||||
},
|
||||
scopes: {
|
||||
'mcp:tools:read': {
|
||||
label: 'View available workflow tools'
|
||||
},
|
||||
'mcp:tools:call': {
|
||||
label: 'Run workflows on your behalf'
|
||||
}
|
||||
},
|
||||
workspace: {
|
||||
personal: 'Personal',
|
||||
team: 'Team',
|
||||
owner: 'Owner',
|
||||
member: 'Member'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +62,8 @@ const challenge: OAuthConsentChallenge = {
|
||||
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: [
|
||||
{
|
||||
@@ -63,52 +94,58 @@ const renderConsent = (
|
||||
})
|
||||
|
||||
describe('OAuthConsentView', () => {
|
||||
it('renders client, resource, and scopes from the challenge', () => {
|
||||
it('renders title, subtitle, and scope checklist', () => {
|
||||
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()
|
||||
// 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('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 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 single workspace read-only without auto-submitting', async () => {
|
||||
it('renders a Native badge when client_application_type is "native"', () => {
|
||||
renderConsent()
|
||||
expect(screen.getByText('Native app')).toBeVisible()
|
||||
})
|
||||
|
||||
it('renders a Web badge when client_application_type is "web"', () => {
|
||||
renderConsent({ client_application_type: 'web' })
|
||||
expect(screen.getByText('Web app')).toBeVisible()
|
||||
})
|
||||
|
||||
it('hides the application-type badge for legacy seeded clients', () => {
|
||||
renderConsent({ client_application_type: '' })
|
||||
expect(screen.queryByText('Native app')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Web app')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('preselects the only workspace and submits with it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const submitDecision = vi.fn()
|
||||
renderConsent(
|
||||
{
|
||||
workspaces: [challenge.workspaces[0]]
|
||||
},
|
||||
submitDecision
|
||||
)
|
||||
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' }))
|
||||
// Single-workspace path: Allow is enabled and submission carries the
|
||||
// sole workspace_id.
|
||||
await user.click(screen.getByRole('button', { name: 'Continue' }))
|
||||
|
||||
expect(submitDecision).toHaveBeenCalledWith({
|
||||
oauthRequestId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
@@ -118,19 +155,76 @@ describe('OAuthConsentView', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('submits deny with the selected workspace', async () => {
|
||||
it('keeps Allow disabled when multiple workspaces are available and none is chosen', () => {
|
||||
renderConsent()
|
||||
const allow = screen.getByRole('button', { name: 'Continue' })
|
||||
expect(allow).toBeDisabled()
|
||||
})
|
||||
|
||||
it('submits deny when the user cancels', async () => {
|
||||
const user = userEvent.setup()
|
||||
const submitDecision = vi.fn()
|
||||
renderConsent({}, submitDecision)
|
||||
renderConsent({ workspaces: [challenge.workspaces[0]] }, submitDecision)
|
||||
|
||||
await user.click(screen.getByLabelText(/Team/))
|
||||
await user.click(screen.getByRole('button', { name: 'Deny' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
expect(submitDecision).toHaveBeenCalledWith({
|
||||
oauthRequestId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
csrfToken: 'csrf-token',
|
||||
decision: 'deny',
|
||||
workspaceId: 'team-workspace'
|
||||
expect(submitDecision).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
decision: 'deny',
|
||||
workspaceId: 'personal-workspace'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('maps OAuthApiError(400) to the expired-request message', async () => {
|
||||
const submitDecision = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new OAuthApiError('expired', 400))
|
||||
const user = userEvent.setup()
|
||||
renderConsent({ workspaces: [challenge.workspaces[0]] }, submitDecision)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Continue' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
'This consent request has expired or has already been used.'
|
||||
)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
it('maps OAuthApiError(403) to the scope-broadening re-prompt message', async () => {
|
||||
const submitDecision = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new OAuthApiError('scope broadening', 403))
|
||||
const user = userEvent.setup()
|
||||
renderConsent({ workspaces: [challenge.workspaces[0]] }, submitDecision)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Continue' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
"The previously approved permissions don't cover this request."
|
||||
)
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
it('maps OAuthApiError(404) to the feature-unavailable message', async () => {
|
||||
const submitDecision = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new OAuthApiError('disabled', 404))
|
||||
const user = userEvent.setup()
|
||||
renderConsent({ workspaces: [challenge.workspaces[0]] }, submitDecision)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Continue' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("This feature isn't available right now.")
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,96 +1,185 @@
|
||||
<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>
|
||||
<main class="mx-auto flex min-h-screen max-w-md flex-col justify-center p-6">
|
||||
<section
|
||||
v-if="challenge"
|
||||
class="flex flex-col gap-6 rounded-2xl border border-solid border-muted bg-(--p-content-background) p-6 shadow-sm"
|
||||
>
|
||||
<header class="flex flex-col items-center gap-3 pt-2 text-center">
|
||||
<div
|
||||
class="flex size-12 items-center justify-center rounded-2xl bg-secondary-background"
|
||||
>
|
||||
<i
|
||||
class="pi pi-key text-xl text-base-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1.5">
|
||||
<h1 class="m-0 text-xl/tight font-semibold">
|
||||
{{
|
||||
t('oauth.consent.title', {
|
||||
client: challenge.client_display_name
|
||||
})
|
||||
}}
|
||||
</h1>
|
||||
<p class="m-0 text-sm text-muted">
|
||||
{{ t('oauth.consent.subtitle', { resource: resourceName }) }}
|
||||
</p>
|
||||
<span
|
||||
v-if="appTypeBadge"
|
||||
class="mt-1 inline-flex items-center gap-1 rounded-full border border-solid border-muted px-2 py-0.5 text-xs text-muted"
|
||||
>
|
||||
<i :class="appTypeBadge.icon" aria-hidden="true" />
|
||||
{{ appTypeBadge.label }}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="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) }}
|
||||
<section class="flex flex-col gap-2">
|
||||
<p class="m-0 text-sm font-medium">
|
||||
{{ t('oauth.consent.workspaceLabel') }}
|
||||
</p>
|
||||
<div
|
||||
v-if="challenge.workspaces.length === 0"
|
||||
class="p-3 text-sm text-muted"
|
||||
>
|
||||
{{ t('oauth.consent.noWorkspaces') }}
|
||||
</div>
|
||||
<ul
|
||||
v-else
|
||||
role="radiogroup"
|
||||
:aria-label="t('oauth.consent.workspaceLabel')"
|
||||
class="m-0 flex scrollbar-custom max-h-72 list-none flex-col gap-1 overflow-y-auto p-0"
|
||||
>
|
||||
<li v-for="workspace in challenge.workspaces" :key="workspace.id">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="selectedWorkspaceId === workspace.id"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full cursor-pointer items-center gap-3 rounded-md border-none bg-transparent px-3 py-2 text-left transition-colors',
|
||||
'hover:bg-secondary-background-hover',
|
||||
'focus-visible:ring-ring focus-visible:ring-1 focus-visible:outline-none',
|
||||
selectedWorkspaceId === workspace.id &&
|
||||
'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
@click="selectedWorkspaceId = workspace.id"
|
||||
>
|
||||
<WorkspaceProfilePic
|
||||
class="size-8 shrink-0 text-sm"
|
||||
:workspace-name="workspace.name"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="truncate text-sm text-base-foreground">
|
||||
{{ workspace.name }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ workspaceSecondaryLabel(workspace) }}
|
||||
</span>
|
||||
</div>
|
||||
<i
|
||||
v-if="selectedWorkspaceId === workspace.id"
|
||||
class="pi pi-check shrink-0 text-sm text-base-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="m-0 text-xs text-muted">
|
||||
{{ t('oauth.consent.workspaceHelp') }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<p class="m-0 text-sm font-medium">
|
||||
{{ t('oauth.consent.permissionsHeader') }}
|
||||
</p>
|
||||
<ul class="m-0 flex list-none flex-col gap-1.5 p-0">
|
||||
<li
|
||||
v-for="scope in challenge.scopes"
|
||||
:key="scope"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<i
|
||||
class="pi pi-check shrink-0 text-sm text-primary-background"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-sm">
|
||||
{{ scopeLabel(scope) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section 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
|
||||
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 text-red-500">
|
||||
<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>
|
||||
|
||||
<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"
|
||||
<footer class="flex flex-col gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
:loading="isSubmitting && lastDecision === 'allow'"
|
||||
:disabled="isSubmitting || !canSubmit"
|
||||
@click="submit('allow')"
|
||||
>
|
||||
{{ t('oauth.consent.allow') }}
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
:loading="isSubmitting && lastDecision === 'deny'"
|
||||
:disabled="isSubmitting"
|
||||
@click="submit('deny')"
|
||||
>
|
||||
{{ t('oauth.consent.deny') }}
|
||||
</Button>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<p v-else-if="errorMessage" role="alert" class="m-0 text-red-500">
|
||||
<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-muted">
|
||||
<p v-else class="m-0 text-center text-sm text-muted">
|
||||
{{ t('oauth.consent.loading') }}
|
||||
</p>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, ref } from 'vue'
|
||||
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'
|
||||
@@ -103,6 +192,8 @@ import {
|
||||
clearOAuthRequestId,
|
||||
getOAuthRequestId
|
||||
} from '@/platform/cloud/oauth/oauthState'
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { initialChallenge, submitDecision = submitOAuthConsentDecision } =
|
||||
defineProps<{
|
||||
@@ -110,39 +201,62 @@ const { initialChallenge, submitDecision = submitOAuthConsentDecision } =
|
||||
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 { t, te } = useI18n()
|
||||
const route = useRoute()
|
||||
const challenge = ref<OAuthConsentChallenge | null>(initialChallenge ?? null)
|
||||
const selectedWorkspaceId = ref(
|
||||
const selectedWorkspaceId = ref<string | undefined>(
|
||||
initialChallenge?.workspaces.length === 1
|
||||
? initialChallenge.workspaces[0].id
|
||||
: ''
|
||||
: undefined
|
||||
)
|
||||
const errorMessage = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
const lastDecision = ref<'allow' | 'deny' | null>(null)
|
||||
|
||||
const selectedWorkspaceIsValid = computed(() => {
|
||||
return Boolean(
|
||||
const resourceName = computed(
|
||||
() =>
|
||||
challenge.value?.resource_display_name ??
|
||||
t('oauth.consent.resourceFallback')
|
||||
)
|
||||
|
||||
const appTypeBadge = computed(() => {
|
||||
const appType = challenge.value?.client_application_type
|
||||
if (appType === 'native') {
|
||||
return { label: t('oauth.consent.appTypeNative'), icon: 'pi pi-desktop' }
|
||||
}
|
||||
if (appType === 'web') {
|
||||
return { label: t('oauth.consent.appTypeWeb'), icon: 'pi pi-globe' }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const selectedWorkspaceIsValid = computed(() =>
|
||||
Boolean(
|
||||
selectedWorkspaceId.value &&
|
||||
challenge.value?.workspaces.some(
|
||||
(workspace) => workspace.id === selectedWorkspaceId.value
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
const canSubmit = computed(() => selectedWorkspaceIsValid.value)
|
||||
|
||||
function labelForScope(scope: string): string {
|
||||
return scope
|
||||
function scopeLabel(scope: string): string {
|
||||
const key = `oauth.scopes.${scope}.label`
|
||||
return te(key) ? t(key) : scope
|
||||
}
|
||||
|
||||
function labelFor(value: string): string {
|
||||
const key = `oauth.workspace.${value}`
|
||||
return te(key) ? t(key) : value
|
||||
}
|
||||
|
||||
// Row's secondary label: personal workspaces show "Personal" (role is
|
||||
// always implicit owner); team workspaces show the role ("Owner"/"Member").
|
||||
function workspaceSecondaryLabel(workspace: OAuthWorkspace): string {
|
||||
return workspace.type === 'personal'
|
||||
? labelFor('personal')
|
||||
: labelFor(workspace.role)
|
||||
}
|
||||
|
||||
function requestIdFromRoute(): string | null {
|
||||
@@ -153,7 +267,9 @@ function requestIdFromRoute(): string | null {
|
||||
|
||||
function initializeWorkspaceSelection(nextChallenge: OAuthConsentChallenge) {
|
||||
selectedWorkspaceId.value =
|
||||
nextChallenge.workspaces.length === 1 ? nextChallenge.workspaces[0].id : ''
|
||||
nextChallenge.workspaces.length === 1
|
||||
? nextChallenge.workspaces[0].id
|
||||
: undefined
|
||||
}
|
||||
|
||||
async function loadChallenge() {
|
||||
@@ -162,35 +278,48 @@ async function loadChallenge() {
|
||||
errorMessage.value = t('oauth.consent.missingRequest')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const nextChallenge = await fetchOAuthConsentChallenge(oauthRequestId)
|
||||
challenge.value = nextChallenge
|
||||
initializeWorkspaceSelection(nextChallenge)
|
||||
const next = await fetchOAuthConsentChallenge(oauthRequestId)
|
||||
challenge.value = next
|
||||
initializeWorkspaceSelection(next)
|
||||
} catch (error) {
|
||||
errorMessage.value =
|
||||
error instanceof Error ? error.message : t('oauth.consent.genericError')
|
||||
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 || !selectedWorkspaceIsValid.value) return
|
||||
if (!challenge.value) return
|
||||
if (decision === 'allow' && !selectedWorkspaceIsValid.value) return
|
||||
|
||||
errorMessage.value = ''
|
||||
isSubmitting.value = true
|
||||
lastDecision.value = decision
|
||||
try {
|
||||
await submitDecision({
|
||||
oauthRequestId: challenge.value.oauth_request_id,
|
||||
csrfToken: challenge.value.csrf_token,
|
||||
decision,
|
||||
workspaceId: selectedWorkspaceId.value
|
||||
// Cloud requires workspace_id on both allow and deny.
|
||||
workspaceId:
|
||||
selectedWorkspaceId.value ?? challenge.value.workspaces[0]?.id ?? ''
|
||||
})
|
||||
clearOAuthRequestId()
|
||||
} catch (error) {
|
||||
errorMessage.value =
|
||||
error instanceof Error ? error.message : t('oauth.consent.genericError')
|
||||
errorMessage.value = messageForError(error)
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
lastDecision.value = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,18 @@ export type OAuthConsentChallenge = {
|
||||
csrf_token: string
|
||||
client_display_name: string
|
||||
resource_display_name?: string
|
||||
/**
|
||||
* Exact registered redirect URI the OAuth client will be sent to on
|
||||
* success/deny. Surfaced verbatim so users can verify the destination
|
||||
* (RFC 8252 loopback for CLIs, HTTPS for web clients).
|
||||
*/
|
||||
redirect_uri?: string
|
||||
/**
|
||||
* RFC 7591 application_type — "native" (CLI/desktop, loopback redirect),
|
||||
* "web" (HTTPS-hosted), or "" for legacy seeded clients. Used to render
|
||||
* a Native / Web badge so users know what kind of app they're authorizing.
|
||||
*/
|
||||
client_application_type?: 'native' | 'web' | ''
|
||||
scopes: string[]
|
||||
workspaces: OAuthWorkspace[]
|
||||
}
|
||||
@@ -39,13 +51,21 @@ export class OAuthApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// Relative URL — let the Vite dev-server proxy (same origin as the FE)
|
||||
// or the production same-host deploy hit ingest.
|
||||
//
|
||||
// Going direct cross-origin via VITE_CLOUD_INGEST_ORIGIN is a footgun:
|
||||
// useSessionCookie POSTs /api/auth/session through the proxy, so the
|
||||
// Set-Cookie response lands on the FE origin. A cross-origin fetch to
|
||||
// a different cloud host wouldn't include that cookie, so the consent
|
||||
// challenge would 302 to login (and trip browser cross-origin redirect
|
||||
// rules to boot — the symptom looks like "CORS error" on a fetch
|
||||
// initiated from /oauth/authorize).
|
||||
//
|
||||
// Keep all OAuth calls same-origin. The Vite proxy / production
|
||||
// ingress is the single point of routing.
|
||||
return path
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response): Promise<string> {
|
||||
|
||||
Reference in New Issue
Block a user