Compare commits
5 Commits
jaeone/fe-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afd42525fe | ||
|
|
0c392e53a2 | ||
|
|
46526cfabd | ||
|
|
fefbe7843c | ||
|
|
6445690ed3 |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 88 KiB |
@@ -21,7 +21,7 @@ export const Default: Story = {
|
||||
args: {
|
||||
title: 'Product',
|
||||
links: [
|
||||
{ label: 'Local', href: '/local' },
|
||||
{ label: 'Desktop', href: '/download' },
|
||||
{ label: 'Cloud', href: '/cloud' },
|
||||
{ label: 'API', href: '/api' },
|
||||
{ label: 'Enterprise', href: '/enterprise' }
|
||||
|
||||
@@ -12,9 +12,9 @@ const meta: Meta<typeof ProductCard> = {
|
||||
})
|
||||
],
|
||||
args: {
|
||||
title: 'Comfy\nLocal',
|
||||
title: 'Comfy\nDesktop',
|
||||
description: 'Run ComfyUI on your own hardware.',
|
||||
cta: 'SEE LOCAL FEATURES',
|
||||
cta: 'SEE DESKTOP FEATURES',
|
||||
href: '#',
|
||||
bg: 'bg-primary-warm-gray'
|
||||
}
|
||||
@@ -31,9 +31,9 @@ export const AllCards: Story = {
|
||||
template: `
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<ProductCard
|
||||
title="Comfy\nLocal"
|
||||
title="Comfy\nDesktop"
|
||||
description="Run ComfyUI on your own hardware."
|
||||
cta="SEE LOCAL FEATURES"
|
||||
cta="SEE DESKTOP FEATURES"
|
||||
href="#"
|
||||
bg="bg-primary-warm-gray"
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Locale } from '../../../i18n/translations'
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { Platform } from '../../../composables/useDownloadUrl'
|
||||
import {
|
||||
downloadUrls,
|
||||
useDownloadUrl
|
||||
@@ -18,13 +19,15 @@ const { locale = 'en', class: customClass = '' } = defineProps<{
|
||||
|
||||
const { downloadUrl, platform, showFallback } = useDownloadUrl()
|
||||
|
||||
const ICONS = {
|
||||
const label = computed(() => t('download.hero.downloadLocal', locale))
|
||||
|
||||
const ICONS: Record<Platform, string> = {
|
||||
windows: '/icons/os/windows.svg',
|
||||
mac: '/icons/os/apple.svg'
|
||||
} as const
|
||||
}
|
||||
|
||||
interface ButtonSpec {
|
||||
key: string
|
||||
key: Platform
|
||||
href: string
|
||||
icon: string
|
||||
ariaLabel?: string
|
||||
@@ -41,19 +44,18 @@ const buttons = computed<ButtonSpec[]>(() => {
|
||||
]
|
||||
}
|
||||
if (showFallback.value) {
|
||||
const label = t('download.hero.downloadLocal', locale)
|
||||
return [
|
||||
{
|
||||
key: 'windows',
|
||||
href: downloadUrls.windows,
|
||||
icon: ICONS.windows,
|
||||
ariaLabel: `${label} — Windows`
|
||||
ariaLabel: `${label.value} — Windows`
|
||||
},
|
||||
{
|
||||
key: 'mac',
|
||||
href: downloadUrls.macArm,
|
||||
icon: ICONS.mac,
|
||||
ariaLabel: `${label} — macOS`
|
||||
ariaLabel: `${label.value} — macOS`
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -77,11 +79,8 @@ const buttons = computed<ButtonSpec[]>(() => {
|
||||
:src="btn.icon"
|
||||
alt=""
|
||||
class="ppformula-text-center size-5 -translate-y-0.75"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="ppformula-text-center">{{
|
||||
t('download.hero.downloadLocal', locale)
|
||||
}}</span>
|
||||
<span class="ppformula-text-center">{{ label }}</span>
|
||||
</span>
|
||||
</BrandButton>
|
||||
</template>
|
||||
|
||||
@@ -7,13 +7,13 @@ export const downloadUrls = {
|
||||
macArm: 'https://download.comfy.org/mac/dmg/arm64'
|
||||
} as const
|
||||
|
||||
type DetectedPlatform = 'windows' | 'mac' | null
|
||||
export type Platform = 'windows' | 'mac'
|
||||
|
||||
function isMobile(ua: string): boolean {
|
||||
return /iphone|ipad|ipod|android/.test(ua)
|
||||
}
|
||||
|
||||
function detectPlatform(ua: string): DetectedPlatform {
|
||||
function detectPlatform(ua: string): Platform | null {
|
||||
if (isMobile(ua)) return null
|
||||
if (ua.includes('win')) return 'windows'
|
||||
if (ua.includes('macintosh') || ua.includes('mac os x')) return 'mac'
|
||||
@@ -23,7 +23,7 @@ function detectPlatform(ua: string): DetectedPlatform {
|
||||
// TODO: Only Windows x64 and macOS arm64 are available today.
|
||||
// When Linux and/or macIntel builds are added, extend detection and URLs here.
|
||||
export function useDownloadUrl() {
|
||||
const platform = ref<DetectedPlatform>(null)
|
||||
const platform = ref<Platform | null>(null)
|
||||
const detected = ref(false)
|
||||
const isMobileUa = ref(false)
|
||||
|
||||
|
||||
@@ -174,16 +174,16 @@ const translations = {
|
||||
'zh-CN': '掌控每个模型、每个节点、每个步骤、每个输出。'
|
||||
},
|
||||
'products.local.title': {
|
||||
en: 'Comfy\nLocal',
|
||||
'zh-CN': 'Comfy\n本地版'
|
||||
en: 'Comfy\nDesktop',
|
||||
'zh-CN': 'Comfy\n桌面版'
|
||||
},
|
||||
'products.local.description': {
|
||||
en: 'Run ComfyUI on your own hardware.',
|
||||
'zh-CN': '在您自己的硬件上运行 ComfyUI。'
|
||||
},
|
||||
'products.local.cta': {
|
||||
en: 'SEE LOCAL FEATURES',
|
||||
'zh-CN': '查看本地版属性'
|
||||
en: 'SEE DESKTOP FEATURES',
|
||||
'zh-CN': '查看桌面版属性'
|
||||
},
|
||||
'products.cloud.title': {
|
||||
en: 'Comfy\nCloud',
|
||||
@@ -1057,18 +1057,18 @@ const translations = {
|
||||
'zh-CN': 'Cloud 与本地运行 ComfyUI 有什么区别?'
|
||||
},
|
||||
'cloud.faq.2.a': {
|
||||
en: 'Cloud runs on powerful remote GPUs and is accessible from any device. Local runs entirely on your computer, giving you full control and offline use.',
|
||||
en: 'Cloud runs on powerful remote GPUs and is accessible from any device. Comfy Desktop runs entirely on your computer, giving you full control and offline use.',
|
||||
'zh-CN':
|
||||
'Cloud 在强大的远程 GPU 上运行,可从任何设备访问。本地版完全在您的电脑上运行,提供完全控制和离线使用。'
|
||||
'Cloud 在强大的远程 GPU 上运行,可从任何设备访问。Comfy 桌面版完全在您的电脑上运行,提供完全控制和离线使用。'
|
||||
},
|
||||
'cloud.faq.3.q': {
|
||||
en: 'Which version should I choose, Comfy Cloud or local ComfyUI (self-hosted)?',
|
||||
'zh-CN': '我应该选择 Comfy Cloud 还是本地 ComfyUI(自托管)?'
|
||||
en: 'Which version should I choose, Comfy Cloud or Comfy Desktop?',
|
||||
'zh-CN': '我应该选择 Comfy Cloud 还是 Comfy 桌面版?'
|
||||
},
|
||||
'cloud.faq.3.a': {
|
||||
en: "Comfy Cloud has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
|
||||
en: "Comfy Cloud has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nComfy Desktop is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.",
|
||||
'zh-CN':
|
||||
'Comfy Cloud 无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
|
||||
'Comfy Cloud 无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\nComfy 桌面版可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。'
|
||||
},
|
||||
'cloud.faq.4.q': {
|
||||
en: 'Do I need a GPU or a strong computer to use Comfy Cloud?',
|
||||
@@ -1091,9 +1091,9 @@ const translations = {
|
||||
'zh-CN': '我可以在 Comfy Cloud 上使用现有的工作流吗?'
|
||||
},
|
||||
'cloud.faq.6.a': {
|
||||
en: 'Yes, your workflows work across Local and Cloud. Just note that only the most popular custom nodes are supported for now, but more will be added soon.',
|
||||
en: 'Yes, your workflows work across Desktop and Cloud. Just note that only the most popular custom nodes are supported for now, but more will be added soon.',
|
||||
'zh-CN':
|
||||
'可以,您的工作流在本地和云端都能使用。请注意,目前仅支持最热门的自定义节点,但很快会添加更多。'
|
||||
'可以,您的工作流在桌面版和云端都能使用。请注意,目前仅支持最热门的自定义节点,但很快会添加更多。'
|
||||
},
|
||||
'cloud.faq.7.q': {
|
||||
en: 'Are all ComfyUI extensions and custom nodes supported?',
|
||||
@@ -1145,9 +1145,9 @@ const translations = {
|
||||
'zh-CN': '合作伙伴节点积分和我的 Cloud 订阅有什么区别?'
|
||||
},
|
||||
'cloud.faq.12.a': {
|
||||
en: 'Comfy Cloud has a credit system that is used for both Partner nodes (formerly API nodes) and running workflows on cloud.\n1. Partner Nodes (Pay-as-you-go): These nodes (formerly called API nodes) run third-party models via API calls and can be used on both Comfy Cloud and Local/Self-Hosted ComfyUI. Each node has its own usage cost, determined by the API provider, and we directly match their pricing.\n2. Running workflows on cloud: Exclusive to Comfy Cloud, you get a set amount of credits per month, with the amount differing based on your plan. More credits can be topped up anytime. Credits are only used up for GPU time while workflows are running — not while editing or building them. No idle costs, no setup, and no infrastructure to manage.',
|
||||
en: 'Comfy Cloud has a credit system that is used for both Partner nodes (formerly API nodes) and running workflows on cloud.\n1. Partner Nodes (Pay-as-you-go): These nodes (formerly called API nodes) run third-party models via API calls and can be used on both Comfy Cloud and Comfy Desktop. Each node has its own usage cost, determined by the API provider, and we directly match their pricing.\n2. Running workflows on cloud: Exclusive to Comfy Cloud, you get a set amount of credits per month, with the amount differing based on your plan. More credits can be topped up anytime. Credits are only used up for GPU time while workflows are running — not while editing or building them. No idle costs, no setup, and no infrastructure to manage.',
|
||||
'zh-CN':
|
||||
'Comfy Cloud 有一个积分系统,用于合作伙伴节点(原 API 节点)和在云端运行工作流。\n1. 合作伙伴节点(按需付费):这些节点(原称 API 节点)通过 API 调用运行第三方模型,可在 Comfy Cloud 和本地/自托管 ComfyUI 上使用。每个节点有其自身的使用成本,由 API 提供商决定,我们直接匹配他们的定价。\n2. 在云端运行工作流:Comfy Cloud 专属,您每月获得一定数量的积分,数量根据您的计划而不同。积分可随时充值。积分仅在工作流运行时用于 GPU 时间——编辑或构建时不消耗。无闲置成本,无需设置,无需管理基础设施。'
|
||||
'Comfy Cloud 有一个积分系统,用于合作伙伴节点(原 API 节点)和在云端运行工作流。\n1. 合作伙伴节点(按需付费):这些节点(原称 API 节点)通过 API 调用运行第三方模型,可在 Comfy Cloud 和 Comfy 桌面版上使用。每个节点有其自身的使用成本,由 API 提供商决定,我们直接匹配他们的定价。\n2. 在云端运行工作流:Comfy Cloud 专属,您每月获得一定数量的积分,数量根据您的计划而不同。积分可随时充值。积分仅在工作流运行时用于 GPU 时间——编辑或构建时不消耗。无闲置成本,无需设置,无需管理基础设施。'
|
||||
},
|
||||
'cloud.faq.13.q': {
|
||||
en: 'Can I cancel my subscription?',
|
||||
@@ -1411,9 +1411,9 @@ const translations = {
|
||||
'zh-CN': '合作伙伴节点'
|
||||
},
|
||||
'pricing.included.feature8.description': {
|
||||
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and local ComfyUI. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
|
||||
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and Comfy Desktop. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
|
||||
'zh-CN':
|
||||
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置,且与月度订阅积分通用。积分可在 Comfy Cloud 和本地 ComfyUI 间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
|
||||
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置,且与月度订阅积分通用。积分可在 Comfy Cloud 和 Comfy 桌面版间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
|
||||
},
|
||||
'pricing.included.feature9.title': {
|
||||
en: 'Job queue',
|
||||
|
||||
@@ -11,9 +11,9 @@ import { t } from '../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Download Comfy — Run AI Locally"
|
||||
title="Download Comfy Desktop — Run AI on Your Hardware"
|
||||
description={t('download.hero.subtitle', 'en')}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux', 'comfyui local']}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
|
||||
>
|
||||
<CloudBannerSection />
|
||||
<HeroSection client:load />
|
||||
|
||||
@@ -11,7 +11,7 @@ import { t } from '../../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="下载 — Comfy"
|
||||
title="下载 Comfy 桌面版 — 在您的硬件上运行 AI"
|
||||
description={t('download.hero.subtitle', 'zh-CN')}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,8 @@ import posthog from 'posthog-js'
|
||||
|
||||
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
|
||||
|
||||
import type { Platform } from '@/composables/useDownloadUrl'
|
||||
|
||||
const POSTHOG_KEY =
|
||||
import.meta.env.PUBLIC_POSTHOG_KEY ??
|
||||
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
|
||||
@@ -39,7 +41,7 @@ export function capturePageview() {
|
||||
}
|
||||
}
|
||||
|
||||
export function captureDownloadClick(platform: string) {
|
||||
export function captureDownloadClick(platform: Platform) {
|
||||
if (!initialized) return
|
||||
try {
|
||||
posthog.capture('website:download_button_clicked', { platform })
|
||||
|
||||
@@ -137,7 +137,8 @@ export const TestIds = {
|
||||
colorPickerCurrentColor: 'color-picker-current-color',
|
||||
colorBlue: 'blue',
|
||||
colorRed: 'red',
|
||||
convertSubgraph: 'convert-to-subgraph-button'
|
||||
convertSubgraph: 'convert-to-subgraph-button',
|
||||
bypass: 'bypass-button'
|
||||
},
|
||||
menu: {
|
||||
moreMenuContent: 'more-menu-content'
|
||||
|
||||
@@ -129,23 +129,18 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
}) => {
|
||||
// A group + a KSampler node
|
||||
await comfyPage.workflow.loadWorkflow('groups/single_group')
|
||||
const bypass = comfyPage.page.getByTestId(TestIds.selectionToolbox.bypass)
|
||||
|
||||
// Select group + node should show bypass button
|
||||
await comfyPage.canvas.focus()
|
||||
await comfyPage.page.keyboard.press('Control+A')
|
||||
await expect(
|
||||
comfyPage.page.locator(
|
||||
'.selection-toolbox *[data-testid="bypass-button"]'
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
// Deselect node (Only group is selected) should hide bypass button
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler'])
|
||||
await expect(
|
||||
comfyPage.page.locator(
|
||||
'.selection-toolbox *[data-testid="bypass-button"]'
|
||||
)
|
||||
).toBeHidden()
|
||||
await expect(bypass).toBeVisible()
|
||||
await comfyPage.keyboard.delete()
|
||||
|
||||
// (Only empty group is selected) should hide bypass button
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
await expect(bypass).toBeHidden()
|
||||
})
|
||||
|
||||
test.describe('Color Picker', () => {
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { getGroupTitlePosition } from '@e2e/fixtures/utils/groupHelpers'
|
||||
|
||||
const CREATE_GROUP_HOTKEY = 'Control+g'
|
||||
|
||||
@@ -217,4 +219,40 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
|
||||
)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Bypassing a group bypasses contents', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.page.keyboard.press('.')
|
||||
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
|
||||
|
||||
const toggleBypass = () =>
|
||||
comfyPage.page.getByTestId(TestIds.selectionToolbox.bypass).click()
|
||||
const bypassCount = () =>
|
||||
comfyPage.page.evaluate(
|
||||
() => graph!.nodes.filter((node) => node.mode === 4).length
|
||||
)
|
||||
expect(await bypassCount()).toBe(0)
|
||||
const groupCount = () => comfyPage.page.evaluate(() => graph!.groups.length)
|
||||
await expect.poll(groupCount, 'create group').toBe(1)
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await ksampler.select()
|
||||
await toggleBypass()
|
||||
await expect.poll(bypassCount, 'setup bypass of single node').toBe(1)
|
||||
|
||||
const groupPos = await getGroupTitlePosition(comfyPage, 'Group')
|
||||
await comfyPage.page.mouse.click(groupPos.x, groupPos.y)
|
||||
await toggleBypass()
|
||||
await expect.poll(bypassCount, 'all nodes are set to bypassed').toBe(7)
|
||||
await toggleBypass()
|
||||
await expect.poll(bypassCount, 'all nodes are unbypassed').toBe(0)
|
||||
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await ksampler.select()
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
|
||||
await toggleBypass()
|
||||
await expect.poll(bypassCount, "won't toggle double selected node").toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -101,6 +101,7 @@ const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
|
||||
|
||||
const {
|
||||
hasAnySelection,
|
||||
hasGroupedNodesSelection,
|
||||
hasMultipleSelection,
|
||||
isSingleNode,
|
||||
isSingleSubgraph,
|
||||
@@ -118,7 +119,10 @@ const showSubgraphButtons = computed(() => isSingleSubgraph.value)
|
||||
|
||||
const showBypass = computed(
|
||||
() =>
|
||||
isSingleNode.value || isSingleSubgraph.value || hasMultipleSelection.value
|
||||
isSingleNode.value ||
|
||||
isSingleSubgraph.value ||
|
||||
hasMultipleSelection.value ||
|
||||
hasGroupedNodesSelection.value
|
||||
)
|
||||
const showLoad3DViewer = computed(() => hasAny3DNodeSelected.value)
|
||||
const showMaskEditor = computed(() => isSingleImageNode.value)
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type {
|
||||
BillingStatus,
|
||||
BillingSubscriptionStatus,
|
||||
CreateTopupResponse,
|
||||
Plan,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse,
|
||||
@@ -16,7 +19,9 @@ export interface SubscriptionInfo {
|
||||
tier: SubscriptionTier | null
|
||||
duration: SubscriptionDuration | null
|
||||
planSlug: string | null
|
||||
/** ISO 8601 */
|
||||
renewalDate: string | null
|
||||
/** ISO 8601 */
|
||||
endDate: string | null
|
||||
isCancelled: boolean
|
||||
hasFunds: boolean
|
||||
@@ -44,6 +49,9 @@ export interface BillingActions {
|
||||
) => Promise<PreviewSubscribeResponse | null>
|
||||
manageSubscription: () => Promise<void>
|
||||
cancelSubscription: () => Promise<void>
|
||||
resubscribe: () => Promise<void>
|
||||
/** `amountCents` must be a whole-dollar multiple of 100. */
|
||||
topup: (amountCents: number) => Promise<CreateTopupResponse | void>
|
||||
fetchPlans: () => Promise<void>
|
||||
/**
|
||||
* Ensures billing is initialized and subscription is active.
|
||||
@@ -65,16 +73,12 @@ export interface BillingState {
|
||||
currentPlanSlug: ComputedRef<string | null>
|
||||
isLoading: Ref<boolean>
|
||||
error: Ref<string | null>
|
||||
/**
|
||||
* Convenience computed for checking if subscription is active.
|
||||
* Equivalent to `subscription.value?.isActive ?? false`
|
||||
*/
|
||||
isActiveSubscription: ComputedRef<boolean>
|
||||
/**
|
||||
* Whether the current billing context has a FREE tier subscription.
|
||||
* Workspace-aware: reflects the active workspace's tier, not the user's personal tier.
|
||||
*/
|
||||
isFreeTier: ComputedRef<boolean>
|
||||
billingStatus: ComputedRef<BillingStatus | null>
|
||||
subscriptionStatus: ComputedRef<BillingSubscriptionStatus | null>
|
||||
tier: ComputedRef<SubscriptionTier | null>
|
||||
renewalDate: ComputedRef<string | null>
|
||||
}
|
||||
|
||||
export interface BillingContext extends BillingState, BillingActions {
|
||||
|
||||
@@ -5,13 +5,17 @@ import type { Plan } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { useBillingContext } from './useBillingContext'
|
||||
|
||||
const { mockTeamWorkspacesEnabled, mockIsPersonal, mockPlans } = vi.hoisted(
|
||||
() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] }
|
||||
})
|
||||
)
|
||||
const {
|
||||
mockTeamWorkspacesEnabled,
|
||||
mockIsPersonal,
|
||||
mockPlans,
|
||||
mockPurchaseCredits
|
||||
} = vi.hoisted(() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockPurchaseCredits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const original = await importOriginal()
|
||||
@@ -50,8 +54,9 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
isActiveSubscription: { value: true },
|
||||
subscriptionTier: { value: 'PRO' },
|
||||
subscriptionDuration: { value: 'MONTHLY' },
|
||||
formattedRenewalDate: { value: 'Jan 1, 2025' },
|
||||
formattedEndDate: { value: '' },
|
||||
subscriptionStatus: {
|
||||
value: { renewal_date: '2025-01-01T00:00:00Z', end_date: null }
|
||||
},
|
||||
isCancelled: { value: false },
|
||||
fetchStatus: vi.fn().mockResolvedValue(undefined),
|
||||
manageSubscription: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -70,6 +75,12 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/composables/auth/useAuthActions', () => ({
|
||||
useAuthActions: () => ({
|
||||
purchaseCredits: mockPurchaseCredits
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
balance: { amount_micros: 5000000 },
|
||||
@@ -129,7 +140,7 @@ describe('useBillingContext', () => {
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
planSlug: null,
|
||||
renewalDate: 'Jan 1, 2025',
|
||||
renewalDate: '2025-01-01T00:00:00Z',
|
||||
endDate: null,
|
||||
isCancelled: false,
|
||||
hasFunds: true
|
||||
@@ -173,6 +184,13 @@ describe('useBillingContext', () => {
|
||||
await expect(manageSubscription()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('converts topup cents to whole dollars for the legacy credit endpoint', async () => {
|
||||
const { topup } = useBillingContext()
|
||||
await topup(500)
|
||||
|
||||
expect(mockPurchaseCredits).toHaveBeenCalledWith(5)
|
||||
})
|
||||
|
||||
it('provides isActiveSubscription convenience computed', () => {
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
expect(isActiveSubscription.value).toBe(true)
|
||||
|
||||
@@ -122,6 +122,15 @@ function useBillingContextInternal(): BillingContext {
|
||||
|
||||
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
|
||||
|
||||
const billingStatus = computed(() =>
|
||||
toValue(activeContext.value.billingStatus)
|
||||
)
|
||||
const subscriptionStatus = computed(() =>
|
||||
toValue(activeContext.value.subscriptionStatus)
|
||||
)
|
||||
const tier = computed(() => toValue(activeContext.value.tier))
|
||||
const renewalDate = computed(() => toValue(activeContext.value.renewalDate))
|
||||
|
||||
function getMaxSeats(tierKey: TierKey): number {
|
||||
if (type.value === 'legacy') return 1
|
||||
|
||||
@@ -218,6 +227,14 @@ function useBillingContextInternal(): BillingContext {
|
||||
return activeContext.value.cancelSubscription()
|
||||
}
|
||||
|
||||
async function resubscribe() {
|
||||
return activeContext.value.resubscribe()
|
||||
}
|
||||
|
||||
async function topup(amountCents: number) {
|
||||
return activeContext.value.topup(amountCents)
|
||||
}
|
||||
|
||||
async function fetchPlans() {
|
||||
return activeContext.value.fetchPlans()
|
||||
}
|
||||
@@ -241,6 +258,10 @@ function useBillingContextInternal(): BillingContext {
|
||||
error,
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
billingStatus,
|
||||
subscriptionStatus,
|
||||
tier,
|
||||
renewalDate,
|
||||
getMaxSeats,
|
||||
|
||||
initialize,
|
||||
@@ -250,6 +271,8 @@ function useBillingContextInternal(): BillingContext {
|
||||
previewSubscribe,
|
||||
manageSubscription,
|
||||
cancelSubscription,
|
||||
resubscribe,
|
||||
topup,
|
||||
fetchPlans,
|
||||
requireActiveSubscription,
|
||||
showSubscriptionDialog
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type {
|
||||
BillingStatus,
|
||||
BillingSubscriptionStatus,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
@@ -24,8 +27,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
isActiveSubscription: legacyIsActiveSubscription,
|
||||
subscriptionTier,
|
||||
subscriptionDuration,
|
||||
formattedRenewalDate,
|
||||
formattedEndDate,
|
||||
subscriptionStatus: legacySubscriptionStatus,
|
||||
isCancelled,
|
||||
fetchStatus: legacyFetchStatus,
|
||||
manageSubscription: legacyManageSubscription,
|
||||
@@ -34,6 +36,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
} = useSubscription()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const authActions = useAuthActions()
|
||||
|
||||
const isInitialized = ref(false)
|
||||
const isLoading = ref(false)
|
||||
@@ -52,8 +55,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
tier: subscriptionTier.value,
|
||||
duration: subscriptionDuration.value,
|
||||
planSlug: null, // Legacy doesn't use plan slugs
|
||||
renewalDate: formattedRenewalDate.value || null,
|
||||
endDate: formattedEndDate.value || null,
|
||||
renewalDate: legacySubscriptionStatus.value?.renewal_date ?? null,
|
||||
endDate: legacySubscriptionStatus.value?.end_date ?? null,
|
||||
isCancelled: isCancelled.value,
|
||||
hasFunds: (authStore.balance?.amount_micros ?? 0) > 0
|
||||
}
|
||||
@@ -75,6 +78,18 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
}
|
||||
})
|
||||
|
||||
// Legacy has no coarse billing_status concept (workspace-only).
|
||||
const billingStatus = computed<BillingStatus | null>(() => null)
|
||||
const subscriptionStatus = computed<BillingSubscriptionStatus | null>(() => {
|
||||
if (isCancelled.value) return 'canceled'
|
||||
if (legacyIsActiveSubscription.value) return 'active'
|
||||
return null
|
||||
})
|
||||
const tier = computed(() => subscriptionTier.value)
|
||||
const renewalDate = computed(
|
||||
() => legacySubscriptionStatus.value?.renewal_date ?? null
|
||||
)
|
||||
|
||||
// Legacy billing doesn't have workspace-style plans
|
||||
const plans = computed(() => [])
|
||||
const currentPlanSlug = computed(() => null)
|
||||
@@ -152,6 +167,16 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
await legacyManageSubscription()
|
||||
}
|
||||
|
||||
async function resubscribe(): Promise<void> {
|
||||
// Legacy has no resubscribe endpoint; resubscribing is a fresh checkout.
|
||||
await legacySubscribe()
|
||||
}
|
||||
|
||||
async function topup(amountCents: number): Promise<void> {
|
||||
// Facade standardizes on cents; legacy /customers/credit takes dollars.
|
||||
await authActions.purchaseCredits(amountCents / 100)
|
||||
}
|
||||
|
||||
async function fetchPlans(): Promise<void> {
|
||||
// Legacy billing doesn't have workspace-style plans
|
||||
// Plans are hardcoded in the UI for legacy subscriptions
|
||||
@@ -179,6 +204,10 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
error,
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
billingStatus,
|
||||
subscriptionStatus,
|
||||
tier,
|
||||
renewalDate,
|
||||
|
||||
// Actions
|
||||
initialize,
|
||||
@@ -188,6 +217,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
previewSubscribe,
|
||||
manageSubscription,
|
||||
cancelSubscription,
|
||||
resubscribe,
|
||||
topup,
|
||||
fetchPlans,
|
||||
requireActiveSubscription,
|
||||
showSubscriptionDialog
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { uniq } from 'es-toolkit'
|
||||
|
||||
import type { LGraphNode, Positionable } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, Reroute } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { collectFromNodes } from '@/utils/graphTraversalUtil'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
/**
|
||||
* Composable for handling selected LiteGraph items filtering and operations.
|
||||
@@ -71,7 +73,13 @@ export function useSelectedLiteGraphItems() {
|
||||
* the prior null-tolerance for callers wired to early-firing commands.
|
||||
*/
|
||||
const getSelectedNodesShallow = (): LGraphNode[] =>
|
||||
Array.from(canvasStore.canvas?.selectedItems ?? []).filter(isLGraphNode)
|
||||
uniq(
|
||||
[...(canvasStore.canvas?.selectedItems ?? [])].flatMap((item) => {
|
||||
if (isLGraphNode(item)) return [item]
|
||||
if (isLGraphGroup(item)) return [...item.children].filter(isLGraphNode)
|
||||
return []
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* Get only the selected nodes (LGraphNode instances) from the canvas.
|
||||
|
||||
@@ -7,7 +7,12 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { isImageNode, isLGraphNode, isLoad3dNode } from '@/utils/litegraphUtil'
|
||||
import {
|
||||
isImageNode,
|
||||
isLGraphGroup,
|
||||
isLGraphNode,
|
||||
isLoad3dNode
|
||||
} from '@/utils/litegraphUtil'
|
||||
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
|
||||
export interface NodeSelectionState {
|
||||
@@ -41,6 +46,11 @@ export function useSelectionState() {
|
||||
const hasAnySelection = computed(() => selectedItems.value.length > 0)
|
||||
const hasSingleSelection = computed(() => selectedItems.value.length === 1)
|
||||
const hasMultipleSelection = computed(() => selectedItems.value.length > 1)
|
||||
const hasGroupedNodesSelection = computed(() =>
|
||||
selectedItems.value.some(
|
||||
(item) => isLGraphGroup(item) && [...item.children].some(isLGraphNode)
|
||||
)
|
||||
)
|
||||
|
||||
const isSingleNode = computed(
|
||||
() => hasSingleSelection.value && isLGraphNode(selectedItems.value[0])
|
||||
@@ -112,6 +122,7 @@ export function useSelectionState() {
|
||||
openNodeInfo,
|
||||
hasAny3DNodeSelected,
|
||||
hasAnySelection,
|
||||
hasGroupedNodesSelection,
|
||||
hasSingleSelection,
|
||||
hasMultipleSelection,
|
||||
isSingleNode,
|
||||
|
||||
@@ -147,7 +147,8 @@ describe('OAuthConsentView', () => {
|
||||
oauthRequestId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
csrfToken: 'csrf-token',
|
||||
decision: 'allow',
|
||||
workspaceId: 'personal-workspace'
|
||||
workspaceId: 'personal-workspace',
|
||||
expectedRedirectUri: 'http://127.0.0.1:50632/cb'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -283,7 +283,8 @@ async function submit(decision: 'allow' | 'deny') {
|
||||
oauthRequestId: challenge.value.oauth_request_id,
|
||||
csrfToken: challenge.value.csrf_token,
|
||||
decision,
|
||||
workspaceId
|
||||
workspaceId,
|
||||
expectedRedirectUri: challenge.value.redirect_uri
|
||||
})
|
||||
clearOAuthRequestId()
|
||||
} catch (error) {
|
||||
|
||||
@@ -220,6 +220,111 @@ describe('submitOAuthConsentDecision', () => {
|
||||
).rejects.toThrow('redirect_url')
|
||||
})
|
||||
|
||||
it('navigates to a reverse-DNS custom-scheme redirect_url (native clients)', async () => {
|
||||
// RFC 8252 native-app callback — the comfy-ios client returns the
|
||||
// authorization code via org.comfy.ios://oauth-callback. The backend
|
||||
// has already validated the URL byte-identically against the client's
|
||||
// registered redirect_uris.
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
okResponse({
|
||||
redirect_url: 'org.comfy.ios://oauth-callback?code=xyz&state=s'
|
||||
})
|
||||
)
|
||||
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',
|
||||
expectedRedirectUri: 'org.comfy.ios://oauth-callback'
|
||||
})
|
||||
|
||||
expect(hrefSetter).toHaveBeenCalledWith(
|
||||
'org.comfy.ios://oauth-callback?code=xyz&state=s'
|
||||
)
|
||||
expect(hrefSetter).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it.for([
|
||||
[
|
||||
'org.comfy.ios://oauth-callback?code=xyz',
|
||||
undefined,
|
||||
'unsafe scheme',
|
||||
'custom scheme with no expectedRedirectUri is unbindable, falls back to the http(s)-only rule'
|
||||
],
|
||||
[
|
||||
'com.evil.app://oauth-callback?code=xyz',
|
||||
'org.comfy.ios://oauth-callback',
|
||||
'does not match',
|
||||
'bound challenge, different scheme: wrong-client redirect'
|
||||
],
|
||||
[
|
||||
'org.comfy.ios://oauth-callback/../steal?code=xyz',
|
||||
'org.comfy.ios://oauth-callback',
|
||||
'does not match',
|
||||
'bound challenge, same scheme but different path'
|
||||
],
|
||||
[
|
||||
'javascript:alert(1)',
|
||||
'javascript:alert(1)',
|
||||
'unsafe scheme',
|
||||
'executable schemes are rejected even if the challenge claims them'
|
||||
],
|
||||
[
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'data:text/html,x',
|
||||
'unsafe scheme',
|
||||
'data: scheme rejected even if the challenge claims it'
|
||||
],
|
||||
[
|
||||
'blob:https://cloud.comfy.org/abc',
|
||||
undefined,
|
||||
'unsafe scheme',
|
||||
'blob: scheme is unsafe'
|
||||
]
|
||||
] as const)(
|
||||
'rejects redirect_url %s (registration %s, expects %s): %s',
|
||||
async ([redirectUrl, expectedRedirectUri, expectedError]) => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
okResponse({ redirect_url: redirectUrl })
|
||||
)
|
||||
|
||||
await expect(
|
||||
submitOAuthConsentDecision({
|
||||
oauthRequestId: validChallenge.oauth_request_id,
|
||||
csrfToken: validChallenge.csrf_token,
|
||||
decision: 'allow',
|
||||
workspaceId: 'personal-workspace',
|
||||
expectedRedirectUri
|
||||
})
|
||||
).rejects.toThrow(expectedError)
|
||||
}
|
||||
)
|
||||
|
||||
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.
|
||||
|
||||
@@ -40,12 +40,33 @@ export type OAuthConsentDecisionParams = {
|
||||
csrfToken: string
|
||||
decision: 'allow' | 'deny'
|
||||
workspaceId: string
|
||||
/**
|
||||
* The challenge's registered `redirect_uri`. When present, the
|
||||
* post-consent navigation must match it (scheme, authority, path) —
|
||||
* the server only appends `code`/`state` query params to the
|
||||
* registered URI, so any other destination is rejected. When absent
|
||||
* (challenges from backends that don't surface it yet), only http(s)
|
||||
* redirects are navigable.
|
||||
*/
|
||||
expectedRedirectUri?: string
|
||||
}
|
||||
|
||||
export type OAuthConsentDecision = (
|
||||
params: OAuthConsentDecisionParams
|
||||
) => Promise<void>
|
||||
|
||||
// Schemes that execute in our origin if navigated. Never navigable,
|
||||
// regardless of what the backend returns. Everything else is governed
|
||||
// by binding to the challenge's registered redirect_uri — no per-client
|
||||
// scheme knowledge lives in the frontend.
|
||||
const EXECUTABLE_SCHEMES: ReadonlySet<string> = new Set([
|
||||
'javascript:',
|
||||
'data:',
|
||||
'blob:',
|
||||
'vbscript:',
|
||||
'about:'
|
||||
])
|
||||
|
||||
export class OAuthApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -118,7 +139,8 @@ export async function submitOAuthConsentDecision({
|
||||
oauthRequestId,
|
||||
csrfToken,
|
||||
decision,
|
||||
workspaceId
|
||||
workspaceId,
|
||||
expectedRedirectUri
|
||||
}: OAuthConsentDecisionParams): Promise<void> {
|
||||
const response = await fetch('/oauth/authorize', {
|
||||
method: 'POST',
|
||||
@@ -144,13 +166,56 @@ export async function submitOAuthConsentDecision({
|
||||
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:') {
|
||||
// Defense in depth at this sink. Two risks: schemes that execute in our
|
||||
// origin (always rejected, below), and the OS routing the authorization
|
||||
// code + state to whichever installed app claims an arbitrary custom
|
||||
// scheme. For the latter we hold the navigation to the redirect the
|
||||
// backend registered for THIS auth request (the challenge's
|
||||
// redirect_uri): the server only ever appends code/state query params
|
||||
// to the registered URI, so scheme, authority, and path must match
|
||||
// exactly. No per-client scheme list lives in the frontend — new native
|
||||
// clients need only their backend registration.
|
||||
const parseTarget = () => {
|
||||
try {
|
||||
return new URL(redirectUrl, globalThis.location.origin)
|
||||
} catch (err) {
|
||||
throw new Error('OAuth consent redirect_url is not a valid URL', {
|
||||
cause: err
|
||||
})
|
||||
}
|
||||
}
|
||||
const target = parseTarget()
|
||||
if (EXECUTABLE_SCHEMES.has(target.protocol)) {
|
||||
throw new Error('OAuth consent redirect_url has an unsafe scheme')
|
||||
}
|
||||
if (expectedRedirectUri) {
|
||||
const parseExpected = () => {
|
||||
try {
|
||||
return new URL(expectedRedirectUri)
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
'OAuth consent challenge redirect_uri is not a valid URL',
|
||||
{ cause: err }
|
||||
)
|
||||
}
|
||||
}
|
||||
const expected = parseExpected()
|
||||
const matchesRegistration =
|
||||
target.protocol === expected.protocol &&
|
||||
target.host === expected.host &&
|
||||
target.pathname === expected.pathname
|
||||
if (!matchesRegistration) {
|
||||
throw new Error(
|
||||
'OAuth consent redirect_url does not match the registered redirect_uri'
|
||||
)
|
||||
}
|
||||
} else if (target.protocol !== 'http:' && target.protocol !== 'https:') {
|
||||
// Challenges that don't surface redirect_uri can't be bound; hold the
|
||||
// pre-existing http(s)-only line for them.
|
||||
throw new Error('OAuth consent redirect_url has an unsafe scheme')
|
||||
}
|
||||
|
||||
globalThis.location.href = redirectUrl
|
||||
// Navigate the parsed URL, not the raw string, so the value validated
|
||||
// above is byte-for-byte the value the browser receives.
|
||||
globalThis.location.href = target.href
|
||||
}
|
||||
|
||||
@@ -196,9 +196,13 @@ export interface PreviewSubscribeResponse {
|
||||
new_plan: PreviewPlanInfo
|
||||
}
|
||||
|
||||
type BillingSubscriptionStatus = 'active' | 'scheduled' | 'ended' | 'canceled'
|
||||
export type BillingSubscriptionStatus =
|
||||
| 'active'
|
||||
| 'scheduled'
|
||||
| 'ended'
|
||||
| 'canceled'
|
||||
|
||||
type BillingStatus =
|
||||
export type BillingStatus =
|
||||
| 'awaiting_payment_method'
|
||||
| 'pending_payment'
|
||||
| 'paid'
|
||||
@@ -233,7 +237,7 @@ interface CreateTopupRequest {
|
||||
|
||||
type TopupStatus = 'pending' | 'completed' | 'failed'
|
||||
|
||||
interface CreateTopupResponse {
|
||||
export interface CreateTopupResponse {
|
||||
billing_op_id: string
|
||||
topup_id: string
|
||||
status: TopupStatus
|
||||
|
||||
@@ -371,7 +371,6 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import {
|
||||
DEFAULT_TIER_KEY,
|
||||
@@ -404,7 +403,8 @@ const {
|
||||
manageSubscription,
|
||||
fetchStatus,
|
||||
fetchBalance,
|
||||
getMaxSeats
|
||||
getMaxSeats,
|
||||
resubscribe
|
||||
} = useBillingContext()
|
||||
|
||||
const { showCancelSubscriptionDialog } = useDialogService()
|
||||
@@ -415,13 +415,12 @@ const isResubscribing = ref(false)
|
||||
async function handleResubscribe() {
|
||||
isResubscribing.value = true
|
||||
try {
|
||||
await workspaceApi.resubscribe()
|
||||
await resubscribe()
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.resubscribeSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to resubscribe'
|
||||
|
||||
@@ -161,7 +161,6 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -177,7 +176,7 @@ const settingsDialog = useSettingsDialog()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const { fetchBalance } = useBillingContext()
|
||||
const { fetchBalance, topup } = useBillingContext()
|
||||
|
||||
const billingOperationStore = useBillingOperationStore()
|
||||
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
|
||||
@@ -257,7 +256,8 @@ async function handleBuy() {
|
||||
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
|
||||
|
||||
const amountCents = payAmount.value * 100
|
||||
const response = await workspaceApi.createTopup(amountCents)
|
||||
const response = await topup(amountCents)
|
||||
if (!response) return
|
||||
|
||||
if (response.status === 'completed') {
|
||||
toast.add({
|
||||
|
||||
@@ -91,10 +91,12 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
previewSubscribe: mockPreviewSubscribe,
|
||||
plans: computed(() => mockPlans.value),
|
||||
fetchStatus: mockFetchStatus,
|
||||
fetchBalance: mockFetchBalance
|
||||
fetchBalance: mockFetchBalance,
|
||||
resubscribe: mockResubscribe
|
||||
})
|
||||
}))
|
||||
|
||||
// Shields the test from the real workspaceApi → @/scripts/api → app.ts import chain
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: { resubscribe: mockResubscribe }
|
||||
}))
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
Plan,
|
||||
PreviewSubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
|
||||
|
||||
type CheckoutStep = 'pricing' | 'preview'
|
||||
@@ -35,8 +34,14 @@ export function useSubscriptionCheckout(emit: {
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { subscribe, previewSubscribe, plans, fetchStatus, fetchBalance } =
|
||||
useBillingContext()
|
||||
const {
|
||||
subscribe,
|
||||
previewSubscribe,
|
||||
plans,
|
||||
fetchStatus,
|
||||
fetchBalance,
|
||||
resubscribe
|
||||
} = useBillingContext()
|
||||
const telemetry = useTelemetry()
|
||||
const billingOperationStore = useBillingOperationStore()
|
||||
|
||||
@@ -170,13 +175,12 @@ export function useSubscriptionCheckout(emit: {
|
||||
async function handleResubscribe() {
|
||||
isResubscribing.value = true
|
||||
try {
|
||||
await workspaceApi.resubscribe()
|
||||
await resubscribe()
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('subscription.resubscribeSuccess'),
|
||||
life: 5000
|
||||
})
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
emit('close', true)
|
||||
} catch (error) {
|
||||
const message =
|
||||
|
||||
@@ -11,7 +11,9 @@ const mockWorkspaceApi = vi.hoisted(() => ({
|
||||
subscribe: vi.fn(),
|
||||
previewSubscribe: vi.fn(),
|
||||
getPaymentPortalUrl: vi.fn(),
|
||||
cancelSubscription: vi.fn()
|
||||
cancelSubscription: vi.fn(),
|
||||
resubscribe: vi.fn(),
|
||||
createTopup: vi.fn()
|
||||
}))
|
||||
|
||||
const mockBillingPlans = vi.hoisted(() => ({
|
||||
@@ -622,6 +624,90 @@ describe('useWorkspaceBilling', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('resubscribe', () => {
|
||||
it('refreshes status and balance after a successful resubscribe', async () => {
|
||||
mockWorkspaceApi.resubscribe.mockResolvedValue(undefined)
|
||||
mockWorkspaceApi.getBillingStatus.mockResolvedValue(activeStatus)
|
||||
mockWorkspaceApi.getBillingBalance.mockResolvedValue(positiveBalance)
|
||||
|
||||
const billing = setupBilling()
|
||||
await billing.resubscribe()
|
||||
|
||||
expect(mockWorkspaceApi.resubscribe).toHaveBeenCalledTimes(1)
|
||||
expect(mockWorkspaceApi.getBillingStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mockWorkspaceApi.getBillingBalance).toHaveBeenCalledTimes(1)
|
||||
expect(billing.subscription.value?.tier).toBe('CREATOR')
|
||||
expect(billing.balance.value?.amountMicros).toBe(5_000_000)
|
||||
expect(billing.error.value).toBeNull()
|
||||
expect(billing.isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('sets error, rethrows, and skips the refresh when the API call fails', async () => {
|
||||
mockWorkspaceApi.resubscribe.mockRejectedValue(
|
||||
new Error('reactivation failed')
|
||||
)
|
||||
|
||||
const billing = setupBilling()
|
||||
|
||||
await expect(billing.resubscribe()).rejects.toThrow('reactivation failed')
|
||||
expect(billing.error.value).toBe('reactivation failed')
|
||||
expect(billing.isLoading.value).toBe(false)
|
||||
expect(mockWorkspaceApi.getBillingStatus).not.toHaveBeenCalled()
|
||||
expect(mockWorkspaceApi.getBillingBalance).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to a generic error message for non-Error rejections', async () => {
|
||||
mockWorkspaceApi.resubscribe.mockRejectedValue('boom')
|
||||
|
||||
const billing = setupBilling()
|
||||
|
||||
await expect(billing.resubscribe()).rejects.toBe('boom')
|
||||
expect(billing.error.value).toBe('Failed to resubscribe')
|
||||
})
|
||||
})
|
||||
|
||||
describe('topup', () => {
|
||||
const topupResponse = {
|
||||
billing_op_id: 'op-topup',
|
||||
topup_id: 'topup-1',
|
||||
status: 'completed' as const,
|
||||
amount_cents: 500
|
||||
}
|
||||
|
||||
it('returns the createTopup response without refreshing status or balance', async () => {
|
||||
mockWorkspaceApi.createTopup.mockResolvedValue(topupResponse)
|
||||
|
||||
const billing = setupBilling()
|
||||
const result = await billing.topup(500)
|
||||
|
||||
expect(mockWorkspaceApi.createTopup).toHaveBeenCalledWith(500)
|
||||
expect(result).toBe(topupResponse)
|
||||
expect(mockWorkspaceApi.getBillingStatus).not.toHaveBeenCalled()
|
||||
expect(mockWorkspaceApi.getBillingBalance).not.toHaveBeenCalled()
|
||||
expect(billing.error.value).toBeNull()
|
||||
expect(billing.isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('sets error and rethrows when the API call fails', async () => {
|
||||
mockWorkspaceApi.createTopup.mockRejectedValue(new Error('card declined'))
|
||||
|
||||
const billing = setupBilling()
|
||||
|
||||
await expect(billing.topup(500)).rejects.toThrow('card declined')
|
||||
expect(billing.error.value).toBe('card declined')
|
||||
expect(billing.isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to a generic error message for non-Error rejections', async () => {
|
||||
mockWorkspaceApi.createTopup.mockRejectedValue('boom')
|
||||
|
||||
const billing = setupBilling()
|
||||
|
||||
await expect(billing.topup(500)).rejects.toBe('boom')
|
||||
expect(billing.error.value).toBe('Failed to top up credits')
|
||||
})
|
||||
})
|
||||
|
||||
describe('plans / currentPlanSlug / fetchPlans', () => {
|
||||
it('prefers the plan slug from status over the billingPlans fallback', async () => {
|
||||
mockBillingPlans.currentPlanSlug.value = 'plans-fallback'
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables
|
||||
import type {
|
||||
BillingBalanceResponse,
|
||||
BillingStatusResponse,
|
||||
CreateTopupResponse,
|
||||
PreviewSubscribeResponse,
|
||||
SubscribeResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
@@ -70,6 +71,13 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
}
|
||||
})
|
||||
|
||||
const billingStatus = computed(() => statusData.value?.billing_status ?? null)
|
||||
const subscriptionStatus = computed(
|
||||
() => statusData.value?.subscription_status ?? null
|
||||
)
|
||||
const tier = computed(() => statusData.value?.subscription_tier ?? null)
|
||||
const renewalDate = computed(() => statusData.value?.renewal_date ?? null)
|
||||
|
||||
const plans = computed(() => billingPlans.plans.value)
|
||||
const currentPlanSlug = computed(
|
||||
() => statusData.value?.plan_slug ?? billingPlans.currentPlanSlug.value
|
||||
@@ -262,6 +270,34 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
}
|
||||
}
|
||||
|
||||
async function resubscribe(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await workspaceApi.resubscribe()
|
||||
await Promise.all([fetchStatus(), fetchBalance()])
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to resubscribe'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function topup(amountCents: number): Promise<CreateTopupResponse> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
return await workspaceApi.createTopup(amountCents)
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to top up credits'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPlans(): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
@@ -303,6 +339,10 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
error,
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
billingStatus,
|
||||
subscriptionStatus,
|
||||
tier,
|
||||
renewalDate,
|
||||
|
||||
// Actions
|
||||
initialize,
|
||||
@@ -312,6 +352,8 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
|
||||
previewSubscribe,
|
||||
manageSubscription,
|
||||
cancelSubscription,
|
||||
resubscribe,
|
||||
topup,
|
||||
fetchPlans,
|
||||
requireActiveSubscription,
|
||||
showSubscriptionDialog
|
||||
|
||||