Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,5 @@ vitest.config.*.timestamp*

# Weekly docs check output
/output.txt

.amp
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
import { computed } from 'vue'

import ComfyQueueButton from '@/components/actionbar/ComfyRunButton/ComfyQueueButton.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'

const { isActiveSubscription } = useSubscription()
const { isActiveSubscription } = useBillingContext()

const currentButton = computed(() =>
isActiveSubscription.value ? ComfyQueueButton : SubscribeToRunButton
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ import Button from '@/components/ui/button/Button.vue'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
Expand All @@ -176,8 +177,9 @@ const dialogService = useDialogService()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { isSubscriptionEnabled } = useSubscription()
const { flags } = useFeatureFlags()

const { isSubscriptionEnabled } = useSubscription()
// Constants
const PRESET_AMOUNTS = [10, 25, 50, 100]
const MIN_AMOUNT = 5
Expand Down Expand Up @@ -256,9 +258,15 @@ async function handleBuy() {

// Close top-up dialog (keep tracking) and open credits panel to show updated balance
handleClose(false)
dialogService.showSettingsDialog(
isSubscriptionEnabled() ? 'subscription' : 'credits'
)

// In workspace mode (personal workspace), show workspace settings panel
// Otherwise, show legacy subscription/credits panel
const settingsPanel = flags.teamWorkspacesEnabled
? 'workspace'
: isSubscriptionEnabled()
? 'subscription'
: 'credits'
dialogService.showSettingsDialog(settingsPanel)
} catch (error) {
console.error('Purchase failed:', error)

Expand Down
295 changes: 295 additions & 0 deletions src/components/dialog/content/TopUpCreditsDialogContentWorkspace.vue
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels like for organization, it would be nicer to put this (and other files) in a src/platform/workspace folder rather than src/components. Then it might be easy to see all the files together and handle the transition better.

Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
<template>
<div
class="flex min-w-[460px] flex-col rounded-2xl border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<!-- Header -->
<div class="flex py-8 items-center justify-between px-8">
<h2 class="text-lg font-bold text-base-foreground m-0">
{{
isInsufficientCredits
? $t('credits.topUp.addMoreCreditsToRun')
: $t('credits.topUp.addMoreCredits')
}}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
@click="() => handleClose()"
>
<i class="icon-[lucide--x] size-6" />
</button>
Comment on lines +14 to +19
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use the shared IconButton and add an accessible label for the close control.
This is an icon-only button, so it needs an explicit accessible name, and the repo prefers common button components over raw <button> elements.

Based on learnings: In Vue files across the ComfyUI_frontend repo, when a button is needed, prefer the repo's common button components from src/components/button/ (IconButton.vue, TextButton.vue, IconTextButton.vue) over plain HTML elements; and for icon-only buttons, provide a clear aria-label or aria-labelledby.

🤖 Prompt for AI Agents
In `@src/components/dialog/content/TopUpCreditsDialogContentWorkspace.vue` around
lines 14 - 19, Replace the raw icon-only <button> in
TopUpCreditsDialogContentWorkspace.vue with the shared IconButton component from
src/components/button/IconButton.vue; keep the existing click handler by
changing `@click`="() => handleClose()" to `@click`="handleClose" (or bind the same
method) and add an explicit accessible label via aria-label="Close" or the
component's label/ariaLabel prop so the close control has a clear accessible
name; remove the original <button> element and ensure the icon markup (<i
class="icon-[lucide--x] size-6" />) is rendered inside IconButton.

</div>
<p
v-if="isInsufficientCredits"
class="text-sm text-muted-foreground m-0 px-8"
>
{{ $t('credits.topUp.insufficientWorkflowMessage') }}
</p>

<!-- Preset amount buttons -->
<div class="px-8">
<h3 class="m-0 text-sm font-normal text-muted-foreground">
{{ $t('credits.topUp.selectAmount') }}
</h3>
<div class="flex gap-2 pt-3">
<Button
v-for="amount in PRESET_AMOUNTS"
:key="amount"
:autofocus="amount === 50"
variant="secondary"
size="lg"
:class="
cn(
'h-10 text-base font-medium w-full focus-visible:ring-secondary-foreground',
selectedPreset === amount && 'bg-secondary-background-selected'
)
"
@click="handlePresetClick(amount)"
>
${{ amount }}
</Button>
</div>
</div>
<!-- Amount (USD) / Credits -->
<div class="flex gap-2 px-8 pt-8">
<!-- You Pay -->
<div class="flex flex-1 flex-col gap-3">
<div class="text-sm text-muted-foreground">
{{ $t('credits.topUp.youPay') }}
</div>
<FormattedNumberStepper
:model-value="payAmount"
:min="0"
:max="MAX_AMOUNT"
:step="getStepAmount"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: it seems like it should be a computed prop rather than a getter.

@update:model-value="handlePayAmountChange"
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<span class="shrink-0 text-base font-semibold text-base-foreground"
>$</span
>
</template>
</FormattedNumberStepper>
</div>

<!-- You Get -->
<div class="flex flex-1 flex-col gap-3">
<div class="text-sm text-muted-foreground">
{{ $t('credits.topUp.youGet') }}
</div>
<FormattedNumberStepper
v-model="creditsModel"
:min="0"
:max="usdToCredits(MAX_AMOUNT)"
:step="getCreditsStepAmount"
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
</template>
</FormattedNumberStepper>
</div>
</div>

<!-- Warnings -->

<p
v-if="isBelowMin"
class="text-sm text-red-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
>
<i class="icon-[lucide--component] size-4" />
{{
$t('credits.topUp.minRequired', {
credits: formatNumber(usdToCredits(MIN_AMOUNT))
})
}}
</p>
<p
v-if="showCeilingWarning"
class="text-sm text-gold-500 m-0 px-8 pt-4 text-center flex items-center justify-center gap-1"
>
<i class="icon-[lucide--component] size-4" />
{{
$t('credits.topUp.maxAllowed', {
credits: formatNumber(usdToCredits(MAX_AMOUNT))
})
}}
<span>{{ $t('credits.topUp.needMore') }}</span>
<a
href="https://www.comfy.org/cloud/enterprise"
target="_blank"
class="ml-1 text-inherit"
>{{ $t('credits.topUp.contactUs') }}</a
>
Comment on lines +118 to +123
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add rel="noopener noreferrer" for external links opened in a new tab.
This prevents reverse-tabnabbing and is standard security practice.

🔒 Safer link attributes
       <a
         href="https://www.comfy.org/cloud/enterprise"
         target="_blank"
+        rel="noopener noreferrer"
         class="ml-1 text-inherit"
         >{{ $t('credits.topUp.contactUs') }}</a
       >
@@
         <a
           :href="pricingUrl"
           target="_blank"
+          rel="noopener noreferrer"
           class="flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
         >

Also applies to: 138-145

🤖 Prompt for AI Agents
In `@src/components/dialog/content/TopUpCreditsDialogContentWorkspace.vue` around
lines 118 - 123, The external anchor(s) in
TopUpCreditsDialogContentWorkspace.vue that use target="_blank" (the <a>
elements rendering {{ $t('credits.topUp.contactUs') }} and the other link around
lines 138-145) must include rel="noopener noreferrer"; update those <a> tags to
add rel="noopener noreferrer" to prevent reverse-tabnabbing and follow security
best practices while keeping target="_blank".

</p>

<div class="pt-8 pb-8 flex flex-col gap-8 px-8">
<Button
:disabled="!isValidAmount || loading || isPolling"
:loading="loading || isPolling"
variant="primary"
size="lg"
class="h-10 justify-center"
@click="handleBuy"
>
{{ $t('subscription.addCredits') }}
</Button>
<div class="flex items-center justify-center gap-1">
<a
:href="pricingUrl"
target="_blank"
class="flex items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
>
{{ $t('credits.topUp.viewPricing') }}
<i class="icon-[lucide--external-link] size-4" />
</a>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check PrimeVue dependency presence/version and import usages.
rg -n '"primevue"' package.json pnpm-lock.yaml
rg -n "primevue/usetoast" -g'*.{ts,vue}'

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 3367


🏁 Script executed:

#!/bin/bash
# Look for toast usage patterns and alternatives
rg -n "Toast|toast" src/components/toast -A 3 -B 1
rg -n "import.*[Tt]oast" --stats | head -20
# Check if there's a custom toast composable
fd -e ts -e vue src | xargs grep -l "GlobalToast\|useToast" | head -10

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 11113


🏁 Script executed:

#!/bin/bash
# Check the usage of useToast in the flagged file
cat -n src/components/dialog/content/TopUpCreditsDialogContentWorkspace.vue | grep -A 10 -B 5 "useToast"

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 1437


Replace PrimeVue useToast with the useToastStore pattern to align with component guidelines.

PrimeVue usage should be avoided in new Vue components. Use useToastStore instead—a pattern already established in the codebase (see GlobalToast.vue and useToastStore composable for reference). Replace import { useToast } from 'primevue/usetoast' and const toast = useToast() with import { useToastStore } from '@/platform/updates/common/toastStore' and dispatch messages through the store instead.

🧰 Tools
🪛 ESLint

[error] 152-152: Unable to resolve path to module 'primevue/usetoast'.

(import-x/no-unresolved)

🤖 Prompt for AI Agents
In `@src/components/dialog/content/TopUpCreditsDialogContentWorkspace.vue` at line
152, Replace the PrimeVue local toast usage with the app-wide toast store:
remove the import of useToast and the call const toast = useToast() and instead
import useToastStore from '@/platform/updates/common/toastStore', get the store
instance via useToastStore(), and dispatch toasts through that store (use the
store's add/dispatch method used by GlobalToast.vue) wherever the code currently
calls toast.*; update any toast invocation sites in
TopUpCreditsDialogContentWorkspace.vue to call the store API with the same
message, severity and options.

import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'

import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
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 { useDialogService } from '@/services/dialogService'
import { useBillingOperationStore } from '@/stores/billingOperationStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'

const { isInsufficientCredits = false } = defineProps<{
isInsufficientCredits?: boolean
}>()

const { t } = useI18n()
const dialogStore = useDialogStore()
const dialogService = useDialogService()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { fetchBalance } = useBillingContext()

const billingOperationStore = useBillingOperationStore()
const isPolling = computed(() => billingOperationStore.hasPendingOperations)

// Constants
const PRESET_AMOUNTS = [10, 25, 50, 100]
const MIN_AMOUNT = 5
const MAX_AMOUNT = 10000

// State
const selectedPreset = ref<number | null>(50)
const payAmount = ref(50)
const showCeilingWarning = ref(false)
const loading = ref(false)

// Computed
const pricingUrl = computed(() =>
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true })
)

const creditsModel = computed({
get: () => usdToCredits(payAmount.value),
set: (newCredits: number) => {
payAmount.value = Math.round(creditsToUsd(newCredits))
selectedPreset.value = null
}
Comment on lines +200 to +205
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clear the ceiling warning when the credits stepper updates.

showCeilingWarning is reset for the USD stepper but not when the credits stepper updates via the computed setter, so the warning can stick after the value drops.

🛠️ Suggested fix
 const creditsModel = computed({
   get: () => usdToCredits(payAmount.value),
   set: (newCredits: number) => {
     payAmount.value = Math.round(creditsToUsd(newCredits))
     selectedPreset.value = null
+    showCeilingWarning.value = false
   }
 })
🤖 Prompt for AI Agents
In `@src/components/dialog/content/TopUpCreditsDialogContentWorkspace.vue` around
lines 218 - 223, The creditsModel computed setter doesn't clear the ceiling
warning, so update the setter for creditsModel (the computed with get ->
usdToCredits(payAmount.value) and set -> Math.round(creditsToUsd(newCredits)))
to also reset showCeilingWarning (e.g., set showCeilingWarning.value = false)
after updating payAmount and before/after clearing selectedPreset; this mirrors
the USD stepper behavior and ensures the warning is cleared when the credits
stepper changes.

})

const isValidAmount = computed(
() => payAmount.value >= MIN_AMOUNT && payAmount.value <= MAX_AMOUNT
)

const isBelowMin = computed(() => payAmount.value < MIN_AMOUNT)

// Utility functions
function formatNumber(num: number): string {
return num.toLocaleString('en-US')
}
Comment on lines +215 to +217
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

formatNumber hardcodes 'en-US' locale.

The n function from useI18n() (line 173) already handles locale-aware number formatting. Use it instead of toLocaleString('en-US') for consistency with the rest of the i18n setup.

💡 Suggested fix
-function formatNumber(num: number): string {
-  return num.toLocaleString('en-US')
-}

Then in the template, replace formatNumber(usdToCredits(MIN_AMOUNT)) with n(usdToCredits(MIN_AMOUNT)) and similarly for MAX_AMOUNT.

As per coding guidelines: Use vue-i18n in Composition API for any string literals.

🤖 Prompt for AI Agents
In `@src/components/dialog/content/TopUpCreditsDialogContentWorkspace.vue` around
lines 215 - 217, The helper function formatNumber currently hardcodes the
'en-US' locale; replace its usage with the i18n number formatter (n) from
useI18n to respect the app locale: remove or stop using formatNumber and update
template calls like formatNumber(usdToCredits(MIN_AMOUNT)) and
formatNumber(usdToCredits(MAX_AMOUNT)) to use n(usdToCredits(MIN_AMOUNT)) and
n(usdToCredits(MAX_AMOUNT)); if formatNumber is kept, refactor it to call n
internally (ensure useI18n's n is imported/available) and remove the hardcoded
toLocaleString('en-US') call.


// Step amount functions
function getStepAmount(currentAmount: number): number {
if (currentAmount < 100) return 5
if (currentAmount < 1000) return 50
return 100
}

function getCreditsStepAmount(currentCredits: number): number {
const usdAmount = creditsToUsd(currentCredits)
return usdToCredits(getStepAmount(usdAmount))
}

// Event handlers
function handlePayAmountChange(value: number) {
payAmount.value = value
selectedPreset.value = null
showCeilingWarning.value = false
}

function handlePresetClick(amount: number) {
showCeilingWarning.value = false
payAmount.value = amount
selectedPreset.value = amount
}

function handleClose(clearTracking = true) {
if (clearTracking) {
clearTopupTracking()
}
dialogStore.closeDialog({ key: 'top-up-credits' })
}

async function handleBuy() {
if (loading.value || !isValidAmount.value) return

loading.value = true
try {
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)

const amountCents = payAmount.value * 100
const response = await workspaceApi.createTopup(amountCents)
Comment on lines +258 to +259
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalize cents to an integer before calling the API.

If the stepper allows fractional USD values, payAmount * 100 can produce fractional cents.

🧮 Suggested fix
-    const amountCents = payAmount.value * 100
+    const amountCents = Math.round(payAmount.value * 100)
🤖 Prompt for AI Agents
In `@src/components/dialog/content/TopUpCreditsDialogContentWorkspace.vue` around
lines 276 - 277, The code calculates cents with const amountCents =
payAmount.value * 100 which can produce fractional cents if the stepper allows
fractional USD; change this to normalize to an integer before calling
workspaceApi.createTopup by computing amountCents from payAmount.value using a
deterministic rounding method (e.g., Math.round or Math.floor depending on
desired behavior) and pass that integer to workspaceApi.createTopup so
createTopup always receives whole cents.


if (response.status === 'completed') {
toast.add({
severity: 'success',
summary: t('credits.topUp.purchaseSuccess'),
life: 5000
})
await fetchBalance()
handleClose(false)
dialogService.showSettingsDialog('workspace')
Comment on lines +268 to +269
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we worried at all about the timing if this has possibility of tearing down the state of the current component?

} else if (response.status === 'pending') {
billingOperationStore.startOperation(response.billing_op_id, 'topup')
} else {
toast.add({
severity: 'error',
summary: t('credits.topUp.purchaseError'),
detail: t('credits.topUp.unknownError'),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT about sending something to Sentry here and below -- to benefit our future selves if we have to debug.

life: 5000
})
}
} catch (error) {
console.error('Purchase failed:', error)

const errorMessage =
error instanceof Error ? error.message : t('credits.topUp.unknownError')
toast.add({
severity: 'error',
summary: t('credits.topUp.purchaseError'),
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
life: 5000
})
} finally {
loading.value = false
}
}
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,9 @@ import { computed, ref, watch } from 'vue'
import UserCredit from '@/components/common/UserCredit.vue'
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
Expand All @@ -138,7 +138,7 @@ const authStore = useFirebaseAuthStore()
const authActions = useFirebaseAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const { isActiveSubscription } = useSubscription()
const { isActiveSubscription } = useBillingContext()
const loading = computed(() => authStore.loading)
const balanceLoading = computed(() => authStore.isFetchingBalance)

Expand Down
Loading
Loading