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:
kishore
2026-05-11 19:04:26 -07:00
parent 36fa2cf43d
commit 0767334ca1
5 changed files with 510 additions and 167 deletions

View File

@@ -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": {

View File

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

View File

@@ -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()
})
})
})

View File

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

View File

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