[API Node] User management (#3567)

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Chenlei Hu <hcl@comfy.org>
This commit is contained in:
Christian Byrne
2025-04-23 06:48:45 +08:00
committed by GitHub
parent 262991db6b
commit 8558f87547
22 changed files with 1174 additions and 155 deletions

View File

@@ -48,6 +48,7 @@
</PanelTemplate>
<AboutPanel />
<UserPanel />
<CreditsPanel />
<Suspense>
<KeybindingPanel />
@@ -96,9 +97,16 @@ import CurrentUserMessage from './setting/CurrentUserMessage.vue'
import FirstTimeUIMessage from './setting/FirstTimeUIMessage.vue'
import PanelTemplate from './setting/PanelTemplate.vue'
import SettingsPanel from './setting/SettingsPanel.vue'
import UserPanel from './setting/UserPanel.vue'
const { defaultPanel } = defineProps<{
defaultPanel?: 'about' | 'keybinding' | 'extension' | 'server-config'
defaultPanel?:
| 'about'
| 'keybinding'
| 'extension'
| 'server-config'
| 'user'
| 'credits'
}>()
const KeybindingPanel = defineAsyncComponent(

View File

@@ -70,7 +70,7 @@
</a>
{{ t('auth.login.andText') }}
<a
href="https://www.comfy.org/privacy-policy"
href="https://www.comfy.org/privacy"
target="_blank"
class="text-blue-500 cursor-pointer"
>

View File

@@ -0,0 +1,47 @@
<template>
<div class="flex flex-col items-center gap-4 p-4">
<div class="flex flex-col items-center text-center">
<i class="pi pi-exclamation-circle mb-4" style="font-size: 2rem" />
<h2 class="text-2xl font-semibold mb-2">
{{ $t(`auth.required.${type}.title`) }}
</h2>
<p class="text-gray-600 mb-4 max-w-md">
{{ $t(`auth.required.${type}.message`) }}
</p>
</div>
<div class="flex gap-4">
<Button
class="w-60"
severity="primary"
:label="$t(`auth.required.${type}.action`)"
@click="openPanel"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useDialogStore } from '@/stores/dialogStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const props = defineProps<{
type: 'signIn' | 'credits'
}>()
const dialogStore = useDialogStore()
const authStore = useFirebaseAuthStore()
const openPanel = () => {
// Close the current dialog
dialogStore.closeDialog({ key: 'signin-required' })
// Open user settings and navigate to appropriate panel
if (props.type === 'credits') {
authStore.openCreditsPanel()
} else {
authStore.openSignInPanel()
}
}
</script>

View File

@@ -0,0 +1,158 @@
<template>
<div class="flex flex-col p-6">
<div
class="flex items-center gap-2"
:class="{ 'text-red-500': isInsufficientCredits }"
>
<i
:class="[
'text-2xl',
isInsufficientCredits ? 'pi pi-exclamation-triangle' : ''
]"
/>
<h2 class="text-2xl font-semibold">
{{
$t(
isInsufficientCredits
? 'credits.topUp.insufficientTitle'
: 'credits.topUp.title'
)
}}
</h2>
</div>
<!-- Error Message -->
<p v-if="isInsufficientCredits" class="text-lg text-muted mt-6">
{{ $t('credits.topUp.insufficientMessage') }}
</p>
<!-- Balance Section -->
<div class="flex justify-between items-center mt-8">
<div class="flex flex-col gap-2">
<div class="text-muted">{{ $t('credits.yourCreditBalance') }}</div>
<div class="flex items-center gap-2">
<Tag
severity="secondary"
icon="pi pi-dollar"
rounded
class="text-amber-400 p-1"
/>
<span class="text-2xl">{{ formattedBalance }}</span>
</div>
</div>
<Button
text
severity="secondary"
:label="$t('credits.creditsHistory')"
icon="pi pi-arrow-up-right"
@click="handleSeeDetails"
/>
</div>
<!-- Amount Input Section -->
<div class="flex flex-col gap-2 mt-8">
<div>
<span class="text-muted">{{ $t('credits.topUp.addCredits') }}</span>
<span class="text-muted text-sm ml-1">{{
$t('credits.topUp.maxAmount')
}}</span>
</div>
<div class="flex items-center gap-2">
<Tag
severity="secondary"
icon="pi pi-dollar"
rounded
class="text-amber-400 p-1"
/>
<InputNumber
v-model="amount"
:min="1"
:max="1000"
:step="1"
mode="currency"
currency="USD"
show-buttons
@blur="handleBlur"
@input="handleInput"
/>
</div>
</div>
<div class="flex justify-end mt-8">
<ProgressSpinner v-if="loading" class="w-8 h-8" />
<Button
v-else
severity="primary"
:label="$t('credits.topUp.buyNow')"
:disabled="!amount || amount > 1000"
:pt="{
root: { class: 'px-8' }
}"
@click="handleBuyNow"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import InputNumber from 'primevue/inputnumber'
import ProgressSpinner from 'primevue/progressspinner'
import Tag from 'primevue/tag'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency, usdToMicros } from '@/utils/formatUtil'
defineProps<{
isInsufficientCredits?: boolean
}>()
const authStore = useFirebaseAuthStore()
const amount = ref<number>(9.99)
const didClickBuyNow = ref(false)
const loading = computed(() => authStore.loading)
const handleBlur = (e: any) => {
if (e.target.value) {
amount.value = parseFloat(e.target.value)
}
}
const handleInput = (e: any) => {
amount.value = e.value
}
const formattedBalance = computed(() => {
if (!authStore.balance) return '0.000'
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
})
const handleSeeDetails = async () => {
const response = await authStore.accessBillingPortal()
if (!response?.billing_portal_url) return
window.open(response.billing_portal_url, '_blank')
}
const handleBuyNow = async () => {
if (!amount.value) return
const response = await authStore.initiateCreditPurchase({
amount_micros: usdToMicros(amount.value),
currency: 'usd'
})
if (!response?.checkout_url) return
didClickBuyNow.value = true
// Go to Stripe checkout page
window.open(response.checkout_url, '_blank')
}
onBeforeUnmount(() => {
if (didClickBuyNow.value) {
// If clicked buy now, then returned back to the dialog and closed, fetch the balance
void authStore.fetchBalance()
}
})
</script>

View File

@@ -19,52 +19,71 @@
rounded
class="text-amber-400 p-1"
/>
<div class="text-3xl font-bold">{{ creditBalance }}</div>
<div class="text-3xl font-bold">{{ formattedBalance }}</div>
</div>
<ProgressSpinner
v-if="loading"
class="w-12 h-12"
style="--pc-spinner-color: #000"
/>
<Button
v-else
:label="$t('credits.purchaseCredits')"
:loading="loading"
@click="handlePurchaseCreditsClick"
/>
</div>
<div class="flex flex-row items-center">
<div v-if="formattedLastUpdateTime" class="text-xs text-muted">
{{ $t('credits.lastUpdated') }}: {{ formattedLastUpdateTime }}
</div>
<Button
:label="$t('credits.purchaseCredits')"
:loading
@click="handlePurchaseCreditsClick"
icon="pi pi-refresh"
text
size="small"
severity="secondary"
@click="() => authStore.fetchBalance()"
/>
</div>
</div>
<Divider class="mt-12" />
<div class="flex justify-between items-center">
<h3 class="text-base font-medium">
{{ $t('credits.creditsHistory') }}
</h3>
<div class="flex justify-between items-center mt-8">
<Button
:label="$t('credits.paymentDetails')"
:label="$t('credits.creditsHistory')"
text
severity="secondary"
icon="pi pi-arrow-up-right"
:loading="loading"
@click="handleCreditsHistoryClick"
/>
</div>
<div class="flex-grow">
<DataTable :value="creditHistory" :show-headers="false">
<Column field="title" :header="$t('g.name')">
<template #body="{ data }">
<div class="text-sm font-medium">{{ data.title }}</div>
<div class="text-xs text-muted">{{ data.timestamp }}</div>
</template>
</Column>
<Column field="amount" :header="$t('g.amount')">
<template #body="{ data }">
<div
:class="[
'text-base font-medium text-center',
data.isPositive ? 'text-sky-500' : 'text-red-400'
]"
>
{{ data.isPositive ? '+' : '-' }}{{ data.amount }}
</div>
</template>
</Column>
</DataTable>
</div>
<template v-if="creditHistory.length > 0">
<div class="flex-grow">
<DataTable :value="creditHistory" :show-headers="false">
<Column field="title" :header="$t('g.name')">
<template #body="{ data }">
<div class="text-sm font-medium">{{ data.title }}</div>
<div class="text-xs text-muted">{{ data.timestamp }}</div>
</template>
</Column>
<Column field="amount" :header="$t('g.amount')">
<template #body="{ data }">
<div
:class="[
'text-base font-medium text-center',
data.isPositive ? 'text-sky-500' : 'text-red-400'
]"
>
{{ data.isPositive ? '+' : '-' }}${{
formatMetronomeCurrency(data.amount, 'usd')
}}
</div>
</template>
</Column>
</DataTable>
</div>
</template>
<Divider />
@@ -74,12 +93,14 @@
text
severity="secondary"
icon="pi pi-question-circle"
@click="handleFaqClick"
/>
<Button
:label="$t('credits.messageSupport')"
text
severity="secondary"
icon="pi pi-comments"
@click="handleMessageSupport"
/>
</div>
</div>
@@ -91,20 +112,15 @@ import Button from 'primevue/button'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import TabPanel from 'primevue/tabpanel'
import Tag from 'primevue/tag'
import { ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { usdToMicros } from '@/utils/formatUtil'
// TODO: Mock data - in a real implementation, this would come from a store or API
const creditBalance = ref(0.05)
// TODO: Either: (1) Get checkout URL that allows setting price on Stripe side, (2) Add number selection on credits panel
const selectedCurrencyAmount = usdToMicros(10)
const selectedCurrency = 'usd' // For now, only USD is supported on comfy-api backend
import { formatMetronomeCurrency } from '@/utils/formatUtil'
interface CreditHistoryItemData {
title: string
@@ -113,46 +129,56 @@ interface CreditHistoryItemData {
isPositive: boolean
}
const { initiateCreditPurchase, loading } = useFirebaseAuthStore()
const { t } = useI18n()
const dialogService = useDialogService()
const authStore = useFirebaseAuthStore()
const loading = computed(() => authStore.loading)
const handlePurchaseCreditsClick = async () => {
const response = await initiateCreditPurchase({
amount_micros: selectedCurrencyAmount,
currency: selectedCurrency
})
// Format balance from micros to dollars
const formattedBalance = computed(() => {
if (!authStore.balance) return '0.00'
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
})
const formattedLastUpdateTime = computed(() =>
authStore.lastBalanceUpdateTime
? authStore.lastBalanceUpdateTime.toLocaleString()
: ''
)
const handlePurchaseCreditsClick = () => {
dialogService.showTopUpCreditsDialog()
}
const handleCreditsHistoryClick = async () => {
const response = await authStore.accessBillingPortal()
if (!response) return
const { checkout_url } = response
if (checkout_url !== undefined) {
// Go to Stripe checkout page
window.open(checkout_url, '_blank')
const { billing_portal_url } = response
if (billing_portal_url) {
window.open(billing_portal_url, '_blank')
}
}
const creditHistory = ref<CreditHistoryItemData[]>([
{
title: 'Kling Text-to-Video v1-6',
timestamp: '2025-04-09, 12:50:08 p.m.',
amount: 4,
isPositive: false
},
{
title: 'Kling Text-to-Video v1-6',
timestamp: '2025-04-09, 12:50:08 p.m.',
amount: 23,
isPositive: false
},
{
title: 'Kling Text-to-Video v1-6',
timestamp: '2025-04-09, 12:50:08 p.m.',
amount: 22,
isPositive: false
},
{
title: 'Free monthly credits',
timestamp: '2025-04-09, 12:46:08 p.m.',
amount: 166,
isPositive: true
}
])
const handleMessageSupport = () => {
dialogService.showIssueReportDialog({
title: t('credits.messageSupport'),
subtitle: t('issueReport.feedbackTitle'),
panelProps: {
errorType: 'BillingSupport',
defaultFields: ['SystemStats', 'Settings']
}
})
}
const handleFaqClick = () => {
window.open('https://drip-art.notion.site/api-nodes-faqs', '_blank')
}
// Fetch initial balance when panel is mounted
onMounted(() => {
void authStore.fetchBalance()
})
const creditHistory = ref<CreditHistoryItemData[]>([])
</script>

View File

@@ -0,0 +1,137 @@
<template>
<TabPanel value="User" class="user-settings-container h-full">
<div class="flex flex-col h-full">
<h2 class="text-xl font-bold mb-2">{{ $t('userSettings.title') }}</h2>
<Divider class="mb-3" />
<div v-if="user" class="flex flex-col gap-2">
<!-- User Avatar if available -->
<div v-if="user.photoURL" class="flex items-center gap-2">
<img
:src="user.photoURL"
:alt="user.displayName || ''"
class="w-8 h-8 rounded-full"
/>
</div>
<div class="flex flex-col gap-0.5">
<h3 class="font-medium">
{{ $t('userSettings.name') }}
</h3>
<div class="text-muted">
{{ user.displayName || $t('userSettings.notSet') }}
</div>
</div>
<div class="flex flex-col gap-0.5">
<h3 class="font-medium">
{{ $t('userSettings.email') }}
</h3>
<a :href="'mailto:' + user.email" class="hover:underline">
{{ user.email }}
</a>
</div>
<div class="flex flex-col gap-0.5">
<h3 class="font-medium">
{{ $t('userSettings.provider') }}
</h3>
<div class="text-muted flex items-center gap-1">
<i :class="providerIcon" />
{{ providerName }}
</div>
</div>
<ProgressSpinner
v-if="loading"
class="w-8 h-8 mt-4"
style="--pc-spinner-color: #000"
/>
<Button
v-else
class="mt-4 w-32"
severity="secondary"
:label="$t('auth.signOut.signOut')"
icon="pi pi-sign-out"
@click="handleSignOut"
/>
</div>
<!-- Login Section -->
<div v-else class="flex flex-col gap-4">
<p class="text-gray-600">
{{ $t('auth.login.title') }}
</p>
<Button
class="w-52"
severity="primary"
:loading="loading"
:label="$t('auth.login.signInOrSignUp')"
icon="pi pi-user"
@click="handleSignIn"
/>
</div>
</div>
</TabPanel>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import TabPanel from 'primevue/tabpanel'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useToastStore } from '@/stores/toastStore'
const toast = useToastStore()
const { t } = useI18n()
const authStore = useFirebaseAuthStore()
const dialogService = useDialogService()
const user = computed(() => authStore.currentUser)
const loading = computed(() => authStore.loading)
const providerName = computed(() => {
const providerId = user.value?.providerData[0]?.providerId
if (providerId?.includes('google')) {
return 'Google'
}
if (providerId?.includes('github')) {
return 'GitHub'
}
return providerId
})
const providerIcon = computed(() => {
const providerId = user.value?.providerData[0]?.providerId
if (providerId?.includes('google')) {
return 'pi pi-google'
}
if (providerId?.includes('github')) {
return 'pi pi-github'
}
return 'pi pi-user'
})
const handleSignOut = async () => {
await authStore.logout()
if (authStore.error) {
toast.addAlert(authStore.error)
} else {
toast.add({
severity: 'success',
summary: t('auth.signOut.success'),
detail: t('auth.signOut.successDetail'),
life: 5000
})
}
}
const handleSignIn = async () => {
await dialogService.showSignInDialog()
}
</script>

View File

@@ -57,7 +57,9 @@
</div>
<!-- Submit Button -->
<ProgressSpinner v-if="loading" class="w-8 h-8" />
<Button
v-else
type="submit"
:label="t('auth.login.loginButton')"
class="h-10 font-medium mt-4"
@@ -71,9 +73,15 @@ import { zodResolver } from '@primevue/forms/resolvers/zod'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { type SignInData, signInSchema } from '@/schemas/signInSchema'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const authStore = useFirebaseAuthStore()
const loading = computed(() => authStore.loading)
const { t } = useI18n()

View File

@@ -23,6 +23,23 @@
@click="workspaceState.focusMode = true"
@contextmenu="showNativeSystemMenu"
/>
<Button
v-if="isAuthenticated"
v-tooltip="{ value: $t('userSettings.title'), showDelay: 300 }"
class="flex-shrink-0 user-profile-button"
severity="secondary"
text
:aria-label="$t('userSettings.title')"
@click="openUserSettings"
>
<template #icon>
<div
class="w-6 h-6 rounded-full bg-neutral-100 dark:bg-neutral-700 flex items-center justify-center"
>
<i class="pi pi-user text-sm" />
</div>
</template>
</Button>
<div
v-show="menuSetting !== 'Bottom'"
class="window-actions-spacer flex-shrink-0"
@@ -46,6 +63,8 @@ import BottomPanelToggleButton from '@/components/topbar/BottomPanelToggleButton
import CommandMenubar from '@/components/topbar/CommandMenubar.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import {
@@ -57,6 +76,10 @@ import {
const workspaceState = useWorkspaceStore()
const settingStore = useSettingStore()
const authStore = useFirebaseAuthStore()
const dialogService = useDialogService()
const isAuthenticated = computed(() => authStore.isAuthenticated)
const workflowTabsPosition = computed(() =>
settingStore.get('Comfy.Workflow.WorkflowTabsPosition')
)
@@ -66,6 +89,10 @@ const showTopMenu = computed(
() => betaMenuEnabled.value && !workspaceState.focusMode
)
const openUserSettings = () => {
dialogService.showSettingsDialog('user')
}
const menuRight = ref<HTMLDivElement | null>(null)
// Menu-right holds legacy topbar elements attached by custom scripts
onMounted(() => {

View File

@@ -9,7 +9,13 @@ import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
export function useSettingUI(
defaultPanel?: 'about' | 'keybinding' | 'extension' | 'server-config'
defaultPanel?:
| 'about'
| 'keybinding'
| 'extension'
| 'server-config'
| 'user'
| 'credits'
) {
const { t } = useI18n()
const firebaseAuthStore = useFirebaseAuthStore()
@@ -55,6 +61,12 @@ export function useSettingUI(
children: []
}
const userPanelNode: SettingTreeNode = {
key: 'user',
label: 'User',
children: []
}
const keybindingPanelNode: SettingTreeNode = {
key: 'keybinding',
label: 'Keybinding',
@@ -84,10 +96,13 @@ export function useSettingUI(
* The default category to show when the dialog is opened.
*/
const defaultCategory = computed<SettingTreeNode>(() => {
return defaultPanel
? settingCategories.value.find((x) => x.key === defaultPanel) ??
settingCategories.value[0]
: settingCategories.value[0]
if (!defaultPanel) return settingCategories.value[0]
// Search through all groups in groupedMenuTreeNodes
for (const group of groupedMenuTreeNodes.value) {
const found = group.children?.find((node) => node.key === defaultPanel)
if (found) return found
}
return settingCategories.value[0]
})
const translateCategory = (node: SettingTreeNode) => ({
@@ -99,16 +114,15 @@ export function useSettingUI(
})
const groupedMenuTreeNodes = computed<SettingTreeNode[]>(() => [
// Account settings - only show when user is authenticated
...(firebaseAuthStore.isAuthenticated
? [
{
key: 'account',
label: 'Account',
children: [creditsPanelNode].map(translateCategory)
}
]
: []),
// Account settings - only show credits when user is authenticated
{
key: 'account',
label: 'Account',
children: [
userPanelNode,
...(firebaseAuthStore.isAuthenticated ? [creditsPanelNode] : [])
].map(translateCategory)
},
// Normal settings stored in the settingStore
{
key: 'settings',

View File

@@ -1,15 +1,15 @@
import { FirebaseOptions } from 'firebase/app'
const DEV_CONFIG: FirebaseOptions = {
apiKey: 'AIzaSyDa_YMeyzV0SkVe92vBZ1tVikWBmOU5KVE',
authDomain: 'dreamboothy-dev.firebaseapp.com',
databaseURL: 'https://dreamboothy-dev-default-rtdb.firebaseio.com',
projectId: 'dreamboothy-dev',
storageBucket: 'dreamboothy-dev.appspot.com',
messagingSenderId: '313257147182',
appId: '1:313257147182:web:be38f6ebf74345fc7618bf',
measurementId: 'G-YEVSMYXSPY'
}
// const DEV_CONFIG: FirebaseOptions = {
// apiKey: 'AIzaSyDa_YMeyzV0SkVe92vBZ1tVikWBmOU5KVE',
// authDomain: 'dreamboothy-dev.firebaseapp.com',
// databaseURL: 'https://dreamboothy-dev-default-rtdb.firebaseio.com',
// projectId: 'dreamboothy-dev',
// storageBucket: 'dreamboothy-dev.appspot.com',
// messagingSenderId: '313257147182',
// appId: '1:313257147182:web:be38f6ebf74345fc7618bf',
// measurementId: 'G-YEVSMYXSPY'
// }
const PROD_CONFIG: FirebaseOptions = {
apiKey: 'AIzaSyC2-fomLqgCjb7ELwta1I9cEarPK8ziTGs',
@@ -26,4 +26,4 @@ const PROD_CONFIG: FirebaseOptions = {
// Otherwise, build with `npm run build` the and set `--front-end-root` to `ComfyUI_frontend/dist`
export const FIREBASE_CONFIG: FirebaseOptions = __USE_PROD_FIREBASE_CONFIG__
? PROD_CONFIG
: DEV_CONFIG
: PROD_CONFIG // Just force prod to save time for now. change back later

View File

@@ -1063,6 +1063,7 @@
"auth": {
"login": {
"title": "Log in to your account",
"signInOrSignUp": "Sign In / Sign Up",
"newUser": "New here?",
"signUp": "Sign up",
"emailLabel": "Email",
@@ -1081,7 +1082,8 @@
"andText": "and",
"privacyLink": "Privacy Policy",
"success": "Login successful",
"failed": "Login failed"
"failed": "Login failed",
"genericErrorMessage": "Sorry, we've encountered an error. Please contact {supportEmail}."
},
"signup": {
"title": "Create an account",
@@ -1094,6 +1096,25 @@
"signIn": "Sign in",
"signUpWithGoogle": "Sign up with Google",
"signUpWithGithub": "Sign up with Github"
},
"signOut": {
"signOut": "Log Out",
"success": "Signed out successfully",
"successDetail": "You have been signed out of your account."
},
"required": {
"signIn": {
"title": "Sign-In Required to Execute Workflow",
"message": "This workflow includes nodes that require an active account. Please log in or create one to continue.",
"hint": "To login go to: Settings > User > Login",
"action": "Open Settings to Login"
},
"credits": {
"title": "Credits Required to Execute Workflow",
"message": "This workflow includes nodes that require credits. Please add credits to your account to continue.",
"hint": "To add credits go to: Settings > User > Credits",
"action": "Open Settings to Add Credits"
}
}
},
"validation": {
@@ -1116,8 +1137,27 @@
"yourCreditBalance": "Your credit balance",
"purchaseCredits": "Purchase Credits",
"creditsHistory": "Credits History",
"paymentDetails": "Payment Details",
"faqs": "FAQs",
"messageSupport": "Message Support"
"messageSupport": "Message Support",
"lastUpdated": "Last updated",
"topUp": {
"title": "Add to Credit Balance",
"insufficientTitle": "Insufficient Credits",
"insufficientMessage": "You don't have enough credits to run this workflow.",
"addCredits": "Add credits to your balance",
"maxAmount": "(Max. $1,000 USD)",
"buyNow": "Buy now"
}
},
"userSettings": {
"title": "User Settings",
"name": "Name",
"email": "Email",
"notSet": "Not set",
"provider": "Sign in method",
"providers": {
"google": "Google",
"github": "GitHub"
}
}
}

View File

@@ -17,6 +17,7 @@
"emailPlaceholder": "Ingresa tu correo electrónico",
"failed": "Inicio de sesión fallido",
"forgotPassword": "¿Olvidaste tu contraseña?",
"genericErrorMessage": "Lo sentimos, hemos encontrado un error. Por favor, contacta con {supportEmail}.",
"loginButton": "Iniciar sesión",
"loginWithGithub": "Iniciar sesión con Github",
"loginWithGoogle": "Iniciar sesión con Google",
@@ -25,12 +26,32 @@
"passwordLabel": "Contraseña",
"passwordPlaceholder": "Ingresa tu contraseña",
"privacyLink": "Política de privacidad",
"signInOrSignUp": "Iniciar sesión / Registrarse",
"signUp": "Regístrate",
"success": "Inicio de sesión exitoso",
"termsLink": "Términos de uso",
"termsText": "Al hacer clic en \"Siguiente\" o \"Registrarse\", aceptas nuestros",
"title": "Inicia sesión en tu cuenta"
},
"required": {
"credits": {
"action": "Abrir configuración para añadir créditos",
"hint": "Para añadir créditos ve a: Configuración > Usuario > Créditos",
"message": "Este flujo de trabajo incluye nodos que requieren créditos. Por favor, añade créditos a tu cuenta para continuar.",
"title": "Créditos requeridos para ejecutar el flujo de trabajo"
},
"signIn": {
"action": "Abrir configuración para iniciar sesión",
"hint": "Para iniciar sesión ve a: Configuración > Usuario > Iniciar sesión",
"message": "Este flujo de trabajo incluye nodos que requieren una cuenta activa. Por favor, inicia sesión o crea una para continuar.",
"title": "Inicio de sesión requerido para ejecutar el flujo de trabajo"
}
},
"signOut": {
"signOut": "Cerrar sesión",
"success": "Sesión cerrada correctamente",
"successDetail": "Has cerrado sesión en tu cuenta."
},
"signup": {
"alreadyHaveAccount": "¿Ya tienes una cuenta?",
"emailLabel": "Correo electrónico",
@@ -96,9 +117,17 @@
"credits": "Créditos",
"creditsHistory": "Historial de créditos",
"faqs": "Preguntas frecuentes",
"lastUpdated": "Última actualización",
"messageSupport": "Contactar soporte",
"paymentDetails": "Detalles de pago",
"purchaseCredits": "Comprar créditos",
"topUp": {
"addCredits": "Agregar créditos a tu saldo",
"buyNow": "Comprar ahora",
"insufficientMessage": "No tienes suficientes créditos para ejecutar este flujo de trabajo.",
"insufficientTitle": "Créditos insuficientes",
"maxAmount": "(Máx. $1,000 USD)",
"title": "Agregar al saldo de créditos"
},
"yourCreditBalance": "Tu saldo de créditos"
},
"dataTypes": {
@@ -1096,6 +1125,17 @@
"next": "Siguiente",
"selectUser": "Selecciona un usuario"
},
"userSettings": {
"email": "Correo electrónico",
"name": "Nombre",
"notSet": "No establecido",
"provider": "Método de inicio de sesión",
"providers": {
"github": "GitHub",
"google": "Google"
},
"title": "Configuración de usuario"
},
"validation": {
"invalidEmail": "Dirección de correo electrónico inválida",
"maxLength": "No debe tener más de {length} caracteres",

View File

@@ -17,6 +17,7 @@
"emailPlaceholder": "Entrez votre email",
"failed": "Échec de la connexion",
"forgotPassword": "Mot de passe oublié?",
"genericErrorMessage": "Désolé, une erreur s'est produite. Veuillez contacter {supportEmail}.",
"loginButton": "Se connecter",
"loginWithGithub": "Se connecter avec Github",
"loginWithGoogle": "Se connecter avec Google",
@@ -25,12 +26,32 @@
"passwordLabel": "Mot de passe",
"passwordPlaceholder": "Entrez votre mot de passe",
"privacyLink": "Politique de confidentialité",
"signInOrSignUp": "Se connecter / Sinscrire",
"signUp": "S'inscrire",
"success": "Connexion réussie",
"termsLink": "Conditions d'utilisation",
"termsText": "En cliquant sur \"Suivant\" ou \"S'inscrire\", vous acceptez nos",
"title": "Connectez-vous à votre compte"
},
"required": {
"credits": {
"action": "Ouvrir les paramètres pour ajouter des crédits",
"hint": "Pour ajouter des crédits, allez dans : Paramètres > Utilisateur > Crédits",
"message": "Ce workflow inclut des nœuds nécessitant des crédits. Veuillez ajouter des crédits à votre compte pour continuer.",
"title": "Crédits requis pour exécuter le workflow"
},
"signIn": {
"action": "Ouvrir les paramètres pour se connecter",
"hint": "Pour vous connecter, allez dans : Paramètres > Utilisateur > Connexion",
"message": "Ce workflow inclut des nœuds nécessitant un compte actif. Veuillez vous connecter ou en créer un pour continuer.",
"title": "Connexion requise pour exécuter le workflow"
}
},
"signOut": {
"signOut": "Se déconnecter",
"success": "Déconnexion réussie",
"successDetail": "Vous avez été déconnecté de votre compte."
},
"signup": {
"alreadyHaveAccount": "Vous avez déjà un compte?",
"emailLabel": "Email",
@@ -96,9 +117,17 @@
"credits": "Crédits",
"creditsHistory": "Historique des crédits",
"faqs": "FAQ",
"lastUpdated": "Dernière mise à jour",
"messageSupport": "Contacter le support",
"paymentDetails": "Détails de paiement",
"purchaseCredits": "Acheter des crédits",
"topUp": {
"addCredits": "Ajouter des crédits à votre solde",
"buyNow": "Acheter maintenant",
"insufficientMessage": "Vous n'avez pas assez de crédits pour exécuter ce workflow.",
"insufficientTitle": "Crédits insuffisants",
"maxAmount": "(Max. 1 000 $ US)",
"title": "Ajouter au solde de crédits"
},
"yourCreditBalance": "Votre solde de crédits"
},
"dataTypes": {
@@ -1096,6 +1125,17 @@
"next": "Suivant",
"selectUser": "Sélectionnez un utilisateur"
},
"userSettings": {
"email": "E-mail",
"name": "Nom",
"notSet": "Non défini",
"provider": "Méthode de connexion",
"providers": {
"github": "GitHub",
"google": "Google"
},
"title": "Paramètres utilisateur"
},
"validation": {
"invalidEmail": "Adresse e-mail invalide",
"maxLength": "Ne doit pas dépasser {length} caractères",

View File

@@ -17,6 +17,7 @@
"emailPlaceholder": "メールアドレスを入力してください",
"failed": "ログイン失敗",
"forgotPassword": "パスワードを忘れましたか?",
"genericErrorMessage": "申し訳ありませんが、エラーが発生しました。{supportEmail} までご連絡ください。",
"loginButton": "ログイン",
"loginWithGithub": "Githubでログイン",
"loginWithGoogle": "Googleでログイン",
@@ -25,12 +26,32 @@
"passwordLabel": "パスワード",
"passwordPlaceholder": "パスワードを入力してください",
"privacyLink": "プライバシーポリシー",
"signInOrSignUp": "サインイン / サインアップ",
"signUp": "サインアップ",
"success": "ログイン成功",
"termsLink": "利用規約",
"termsText": "「次へ」または「サインアップ」をクリックすると、私たちの",
"title": "アカウントにログインする"
},
"required": {
"credits": {
"action": "設定を開いてクレジットを追加",
"hint": "クレジットを追加するには: 設定 > ユーザー > クレジット",
"message": "このワークフローにはクレジットが必要なノードが含まれています。続行するにはアカウントにクレジットを追加してください。",
"title": "ワークフロー実行にはクレジットが必要です"
},
"signIn": {
"action": "設定を開いてログイン",
"hint": "ログインするには: 設定 > ユーザー > ログイン",
"message": "このワークフローにはアクティブなアカウントが必要なノードが含まれています。続行するにはログインまたはアカウントを作成してください。",
"title": "ワークフロー実行にはサインインが必要です"
}
},
"signOut": {
"signOut": "ログアウト",
"success": "正常にサインアウトしました",
"successDetail": "アカウントからサインアウトしました。"
},
"signup": {
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
"emailLabel": "メール",
@@ -96,9 +117,17 @@
"credits": "クレジット",
"creditsHistory": "クレジット履歴",
"faqs": "よくある質問",
"lastUpdated": "最終更新",
"messageSupport": "サポートにメッセージ",
"paymentDetails": "支払い詳細",
"purchaseCredits": "クレジットを購入",
"topUp": {
"addCredits": "残高にクレジットを追加",
"buyNow": "今すぐ購入",
"insufficientMessage": "このワークフローを実行するのに十分なクレジットがありません。",
"insufficientTitle": "クレジット不足",
"maxAmount": "(最大 $1,000 USD",
"title": "クレジット残高を追加"
},
"yourCreditBalance": "あなたのクレジット残高"
},
"dataTypes": {
@@ -1096,6 +1125,17 @@
"next": "次へ",
"selectUser": "ユーザーを選択"
},
"userSettings": {
"email": "メールアドレス",
"name": "名前",
"notSet": "未設定",
"provider": "サインイン方法",
"providers": {
"github": "GitHub",
"google": "Google"
},
"title": "ユーザー設定"
},
"validation": {
"invalidEmail": "無効なメールアドレス",
"maxLength": "{length}文字以下でなければなりません",

View File

@@ -17,6 +17,7 @@
"emailPlaceholder": "이메일을 입력하세요",
"failed": "로그인 실패",
"forgotPassword": "비밀번호를 잊으셨나요?",
"genericErrorMessage": "죄송합니다. 오류가 발생했습니다. {supportEmail}로 문의해 주세요.",
"loginButton": "로그인",
"loginWithGithub": "Github로 로그인",
"loginWithGoogle": "구글로 로그인",
@@ -25,12 +26,32 @@
"passwordLabel": "비밀번호",
"passwordPlaceholder": "비밀번호를 입력하세요",
"privacyLink": "개인정보 보호정책",
"signInOrSignUp": "로그인 / 회원가입",
"signUp": "가입하기",
"success": "로그인 성공",
"termsLink": "이용 약관",
"termsText": "\"다음\" 또는 \"가입하기\"를 클릭하면 우리의",
"title": "계정에 로그인"
},
"required": {
"credits": {
"action": "설정에서 크레딧 추가 열기",
"hint": "크레딧을 추가하려면: 설정 > 사용자 > 크레딧 으로 이동하세요.",
"message": "이 워크플로에는 크레딧이 필요한 노드가 포함되어 있습니다. 계속하려면 계정에 크레딧을 추가하세요.",
"title": "워크플로 실행을 위한 크레딧 필요"
},
"signIn": {
"action": "설정에서 로그인 열기",
"hint": "로그인하려면: 설정 > 사용자 > 로그인 으로 이동하세요.",
"message": "이 워크플로에는 활성 계정이 필요한 노드가 포함되어 있습니다. 계속하려면 로그인하거나 계정을 생성하세요.",
"title": "워크플로 실행을 위한 로그인 필요"
}
},
"signOut": {
"signOut": "로그아웃",
"success": "성공적으로 로그아웃되었습니다",
"successDetail": "계정에서 로그아웃되었습니다."
},
"signup": {
"alreadyHaveAccount": "이미 계정이 있으신가요?",
"emailLabel": "이메일",
@@ -96,9 +117,17 @@
"credits": "크레딧",
"creditsHistory": "크레딧 내역",
"faqs": "자주 묻는 질문",
"lastUpdated": "마지막 업데이트",
"messageSupport": "지원 문의",
"paymentDetails": "결제 정보",
"purchaseCredits": "크레딧 구매",
"topUp": {
"addCredits": "잔액에 크레딧 추가",
"buyNow": "지금 구매",
"insufficientMessage": "이 워크플로우를 실행하기에 크레딧이 부족합니다.",
"insufficientTitle": "크레딧 부족",
"maxAmount": "(최대 $1,000 USD)",
"title": "크레딧 잔액 충전"
},
"yourCreditBalance": "보유 크레딧 잔액"
},
"dataTypes": {
@@ -1096,6 +1125,17 @@
"next": "다음",
"selectUser": "사용자 선택"
},
"userSettings": {
"email": "이메일",
"name": "이름",
"notSet": "설정되지 않음",
"provider": "로그인 방법",
"providers": {
"github": "GitHub",
"google": "Google"
},
"title": "사용자 설정"
},
"validation": {
"invalidEmail": "유효하지 않은 이메일 주소",
"maxLength": "{length}자를 초과할 수 없습니다",

View File

@@ -17,6 +17,7 @@
"emailPlaceholder": "Введите вашу электронную почту",
"failed": "Вход не удался",
"forgotPassword": "Забыли пароль?",
"genericErrorMessage": "Извините, произошла ошибка. Пожалуйста, свяжитесь с {supportEmail}.",
"loginButton": "Войти",
"loginWithGithub": "Войти через Github",
"loginWithGoogle": "Войти через Google",
@@ -25,12 +26,32 @@
"passwordLabel": "Пароль",
"passwordPlaceholder": "Введите ваш пароль",
"privacyLink": "Политикой конфиденциальности",
"signInOrSignUp": "Войти / Зарегистрироваться",
"signUp": "Зарегистрироваться",
"success": "Вход выполнен успешно",
"termsLink": "Условиями использования",
"termsText": "Нажимая \"Далее\" или \"Зарегистрироваться\", вы соглашаетесь с нашими",
"title": "Войдите в свой аккаунт"
},
"required": {
"credits": {
"action": "Открыть настройки для пополнения кредитов",
"hint": "Чтобы пополнить кредиты, перейдите в: Настройки > Пользователь > Кредиты",
"message": "Этот рабочий процесс содержит узлы, для которых необходимы кредиты. Пожалуйста, пополните баланс, чтобы продолжить.",
"title": "Требуются кредиты для выполнения рабочего процесса"
},
"signIn": {
"action": "Открыть настройки для входа",
"hint": "Чтобы войти, перейдите в: Настройки > Пользователь > Вход",
"message": "Этот рабочий процесс содержит узлы, для которых необходима активная учетная запись. Пожалуйста, войдите или создайте учетную запись, чтобы продолжить.",
"title": "Требуется вход для выполнения рабочего процесса"
}
},
"signOut": {
"signOut": "Выйти",
"success": "Вы успешно вышли из системы",
"successDetail": "Вы вышли из своей учетной записи."
},
"signup": {
"alreadyHaveAccount": "Уже есть аккаунт?",
"emailLabel": "Электронная почта",
@@ -96,9 +117,17 @@
"credits": "Кредиты",
"creditsHistory": "История кредитов",
"faqs": "Часто задаваемые вопросы",
"lastUpdated": "Последнее обновление",
"messageSupport": "Связаться с поддержкой",
"paymentDetails": "Детали оплаты",
"purchaseCredits": "Купить кредиты",
"topUp": {
"addCredits": "Добавить кредиты на баланс",
"buyNow": "Купить сейчас",
"insufficientMessage": "У вас недостаточно кредитов для запуска этого рабочего процесса.",
"insufficientTitle": "Недостаточно кредитов",
"maxAmount": "(Макс. $1,000 USD)",
"title": "Пополнить баланс кредитов"
},
"yourCreditBalance": "Ваш баланс кредитов"
},
"dataTypes": {
@@ -1096,6 +1125,17 @@
"next": "Далее",
"selectUser": "Выберите пользователя"
},
"userSettings": {
"email": "Электронная почта",
"name": "Имя",
"notSet": "Не задано",
"provider": "Способ входа",
"providers": {
"github": "GitHub",
"google": "Google"
},
"title": "Настройки пользователя"
},
"validation": {
"invalidEmail": "Недействительный адрес электронной почты",
"maxLength": "Должно быть не более {length} символов",

View File

@@ -17,6 +17,7 @@
"emailPlaceholder": "输入您的电子邮件",
"failed": "登录失败",
"forgotPassword": "忘记密码?",
"genericErrorMessage": "抱歉,我们遇到了一些错误。请联系 {supportEmail}。",
"loginButton": "登录",
"loginWithGithub": "使用Github登录",
"loginWithGoogle": "使用Google登录",
@@ -25,12 +26,32 @@
"passwordLabel": "密码",
"passwordPlaceholder": "输入您的密码",
"privacyLink": "隐私政策",
"signInOrSignUp": "登录 / 注册",
"signUp": "注册",
"success": "登录成功",
"termsLink": "使用条款",
"termsText": "点击“下一步”或“注册”即表示您同意我们的",
"title": "登录您的账户"
},
"required": {
"credits": {
"action": "打开设置进行充值",
"hint": "要充值,请前往:设置 > 用户 > 积分",
"message": "此工作流包含需要积分的节点。请为您的账户充值以继续。",
"title": "需要积分以执行工作流"
},
"signIn": {
"action": "打开设置进行登录",
"hint": "要登录,请前往:设置 > 用户 > 登录",
"message": "此工作流包含需要活跃账户的节点。请登录或创建账户以继续。",
"title": "需要登录以执行工作流"
}
},
"signOut": {
"signOut": "退出登录",
"success": "成功退出登录",
"successDetail": "您已成功退出账户。"
},
"signup": {
"alreadyHaveAccount": "已经有账户了?",
"emailLabel": "电子邮件",
@@ -96,9 +117,17 @@
"credits": "积分",
"creditsHistory": "积分历史",
"faqs": "常见问题",
"lastUpdated": "最近更新",
"messageSupport": "联系客服",
"paymentDetails": "支付详情",
"purchaseCredits": "购买积分",
"topUp": {
"addCredits": "为您的余额充值",
"buyNow": "立即购买",
"insufficientMessage": "您的积分不足,无法运行此工作流。",
"insufficientTitle": "积分不足",
"maxAmount": "(最高 $1,000 美元)",
"title": "充值余额"
},
"yourCreditBalance": "您的积分余额"
},
"dataTypes": {
@@ -1096,6 +1125,17 @@
"next": "下一步",
"selectUser": "选择用户"
},
"userSettings": {
"email": "电子邮件",
"name": "名称",
"notSet": "未设置",
"provider": "登录方式",
"providers": {
"github": "GitHub",
"google": "Google"
},
"title": "用户设置"
},
"validation": {
"invalidEmail": "无效的电子邮件地址",
"maxLength": "不能超过{length}个字符",

View File

@@ -673,7 +673,17 @@ export class ComfyApp {
})
api.addEventListener('execution_error', ({ detail }) => {
useDialogService().showExecutionErrorDialog(detail)
// Check if this is an auth-related error or credits-related error
if (detail.exception_message === 'Please login first to use this node.') {
useDialogService().showSignInRequiredDialog({ type: 'signIn' })
} else if (
detail.exception_message ===
'Payment Required: Please add credits to your account to use this node.'
) {
useDialogService().showSignInRequiredDialog({ type: 'credits' })
} else {
useDialogService().showExecutionErrorDialog(detail)
}
this.canvas.draw(true, true)
})

View File

@@ -8,6 +8,8 @@ import MissingModelsWarning from '@/components/dialog/content/MissingModelsWarni
import PromptDialogContent from '@/components/dialog/content/PromptDialogContent.vue'
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue'
import SignInContent from '@/components/dialog/content/SignInContent.vue'
import SignInRequiredDialogContent from '@/components/dialog/content/SignInRequiredDialogContent.vue'
import TopUpCreditsDialogContent from '@/components/dialog/content/TopUpCreditsDialogContent.vue'
import ManagerDialogContent from '@/components/dialog/content/manager/ManagerDialogContent.vue'
import ManagerHeader from '@/components/dialog/content/manager/ManagerHeader.vue'
import ManagerProgressFooter from '@/components/dialog/footer/ManagerProgressFooter.vue'
@@ -51,7 +53,13 @@ export const useDialogService = () => {
}
function showSettingsDialog(
panel?: 'about' | 'keybinding' | 'extension' | 'server-config'
panel?:
| 'about'
| 'keybinding'
| 'extension'
| 'server-config'
| 'user'
| 'credits'
) {
const props = panel ? { props: { defaultPanel: panel } } : undefined
@@ -340,6 +348,29 @@ export const useDialogService = () => {
})
}
function showSignInRequiredDialog(options: { type: 'signIn' | 'credits' }) {
dialogStore.showDialog({
key: 'signin-required',
component: SignInRequiredDialogContent,
props: options
})
}
function showTopUpCreditsDialog(options?: {
isInsufficientCredits?: boolean
}) {
return dialogStore.showDialog({
key: 'top-up-credits',
component: TopUpCreditsDialogContent,
props: options,
dialogComponentProps: {
pt: {
header: { class: '!p-3' }
}
}
})
}
return {
showLoadWorkflowWarning,
showMissingModelsWarning,
@@ -353,6 +384,8 @@ export const useDialogService = () => {
showErrorDialog,
showApiNodesSignInDialog,
showSignInDialog,
showSignInRequiredDialog,
showTopUpCreditsDialog,
prompt,
confirm
}

View File

@@ -16,12 +16,24 @@ import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useFirebaseAuth } from 'vuefire'
import { t } from '@/i18n'
import { useDialogService } from '@/services/dialogService'
import { operations } from '@/types/comfyRegistryTypes'
import { useToastStore } from './toastStore'
type CreditPurchaseResponse =
operations['InitiateCreditPurchase']['responses']['201']['content']['application/json']
type CreditPurchasePayload =
operations['InitiateCreditPurchase']['requestBody']['content']['application/json']
type CreateCustomerResponse =
operations['createCustomer']['responses']['201']['content']['application/json']
type GetCustomerBalanceResponse =
operations['GetCustomerBalance']['responses']['200']['content']['application/json']
type AccessBillingPortalResponse =
operations['AccessBillingPortal']['responses']['200']['content']['application/json']
type AccessBillingPortalReqBody =
operations['AccessBillingPortal']['requestBody']
// TODO: Switch to prod api based on environment (requires prod api to be ready)
const API_BASE_URL = 'https://stagingapi.comfy.org'
@@ -32,6 +44,11 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
const error = ref<string | null>(null)
const currentUser = ref<User | null>(null)
const isInitialized = ref(false)
const customerCreated = ref(false)
// Balance state
const balance = ref<GetCustomerBalanceResponse | null>(null)
const lastBalanceUpdateTime = ref<Date | null>(null)
// Providers
const googleProvider = new GoogleAuthProvider()
@@ -51,13 +68,92 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
onAuthStateChanged(auth, (user) => {
currentUser.value = user
isInitialized.value = true
// Reset balance when auth state changes
balance.value = null
lastBalanceUpdateTime.value = null
})
} else {
error.value = 'Firebase Auth not available from VueFire'
}
const showAuthErrorToast = () => {
useToastStore().add({
summary: t('g.error'),
detail: t('auth.login.genericErrorMessage', {
supportEmail: 'support@comfy.org'
}),
severity: 'error'
})
}
const getIdToken = async (): Promise<string | null> => {
if (currentUser.value) {
return currentUser.value.getIdToken()
}
return null
}
const fetchBalance = async (): Promise<GetCustomerBalanceResponse | null> => {
const token = await getIdToken()
if (!token) {
error.value = 'Cannot fetch balance: User not authenticated'
return null
}
const response = await fetch(`${API_BASE_URL}/customers/balance`, {
headers: {
Authorization: `Bearer ${token}`
}
})
if (!response.ok) {
if (response.status === 404) {
// Customer not found is expected for new users
return null
}
const errorData = await response.json()
error.value = `Failed to fetch balance: ${errorData.message}`
return null
}
const balanceData = await response.json()
// Update the last balance update time
lastBalanceUpdateTime.value = new Date()
balance.value = balanceData
return balanceData
}
const createCustomer = async (
token: string
): Promise<CreateCustomerResponse> => {
const createCustomerRes = await fetch(`${API_BASE_URL}/customers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
if (!createCustomerRes.ok) {
throw new Error(
`Failed to create customer: ${createCustomerRes.statusText}`
)
}
const createCustomerResJson: CreateCustomerResponse =
await createCustomerRes.json()
if (!createCustomerResJson?.id) {
throw new Error('Failed to create customer: No customer ID returned')
}
return createCustomerResJson
}
const executeAuthAction = async <T>(
action: (auth: Auth) => Promise<T>
action: (auth: Auth) => Promise<T>,
options: {
createCustomer?: boolean
} = {}
): Promise<T> => {
if (!auth) throw new Error('Firebase Auth not initialized')
@@ -65,9 +161,21 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
error.value = null
try {
return await action(auth)
const result = await action(auth)
// Create customer if needed
if (options?.createCustomer) {
const token = await getIdToken()
if (!token) {
throw new Error('Cannot create customer: User not authenticated')
}
await createCustomer(token)
}
return result
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Unknown error'
showAuthErrorToast()
throw e
} finally {
loading.value = false
@@ -78,38 +186,38 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
email: string,
password: string
): Promise<UserCredential> =>
executeAuthAction((authInstance) =>
signInWithEmailAndPassword(authInstance, email, password)
executeAuthAction(
(authInstance) =>
signInWithEmailAndPassword(authInstance, email, password),
{ createCustomer: true }
)
const register = async (
email: string,
password: string
): Promise<UserCredential> =>
executeAuthAction((authInstance) =>
createUserWithEmailAndPassword(authInstance, email, password)
): Promise<UserCredential> => {
return executeAuthAction(
(authInstance) =>
createUserWithEmailAndPassword(authInstance, email, password),
{ createCustomer: true }
)
}
const loginWithGoogle = async (): Promise<UserCredential> =>
executeAuthAction((authInstance) =>
signInWithPopup(authInstance, googleProvider)
executeAuthAction(
(authInstance) => signInWithPopup(authInstance, googleProvider),
{ createCustomer: true }
)
const loginWithGithub = async (): Promise<UserCredential> =>
executeAuthAction((authInstance) =>
signInWithPopup(authInstance, githubProvider)
executeAuthAction(
(authInstance) => signInWithPopup(authInstance, githubProvider),
{ createCustomer: true }
)
const logout = async (): Promise<void> =>
executeAuthAction((authInstance) => signOut(authInstance))
const getIdToken = async (): Promise<string | null> => {
if (currentUser.value) {
return currentUser.value.getIdToken()
}
return null
}
const addCredits = async (
requestBodyContent: CreditPurchasePayload
): Promise<CreditPurchaseResponse | null> => {
@@ -119,6 +227,12 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
return null
}
// Ensure customer was created during login/registration
if (!customerCreated.value) {
await createCustomer(token)
customerCreated.value = true
}
const response = await fetch(`${API_BASE_URL}/customers/credit`, {
method: 'POST',
headers: {
@@ -134,7 +248,6 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
return null
}
// TODO: start polling /listBalance until balance is updated or n retries fail or report no change
return response.json()
}
@@ -143,12 +256,51 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
): Promise<CreditPurchaseResponse | null> =>
executeAuthAction((_) => addCredits(requestBodyContent))
const openSignInPanel = () => {
useDialogService().showSettingsDialog('user')
}
const openCreditsPanel = () => {
useDialogService().showSettingsDialog('credits')
}
const accessBillingPortal = async (
requestBody?: AccessBillingPortalReqBody
): Promise<AccessBillingPortalResponse | null> => {
const token = await getIdToken()
if (!token) {
error.value = 'Cannot access billing portal: User not authenticated'
return null
}
const response = await fetch(`${API_BASE_URL}/customers/billing`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
...(requestBody && {
body: JSON.stringify(requestBody)
})
})
if (!response.ok) {
const errorData = await response.json()
error.value = `Failed to access billing portal: ${errorData.message}`
return null
}
return response.json()
}
return {
// State
loading,
error,
currentUser,
isInitialized,
balance,
lastBalanceUpdateTime,
// Getters
isAuthenticated,
@@ -162,6 +314,10 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
getIdToken,
loginWithGoogle,
loginWithGithub,
initiateCreditPurchase
initiateCreditPurchase,
openSignInPanel,
openCreditsPanel,
fetchBalance,
accessBillingPortal
}
})

View File

@@ -416,6 +416,49 @@ export function compareVersions(
return 0
}
/**
* Converts a currency amount to Metronome's integer representation.
* For USD, converts to cents (multiplied by 100).
* For all other currencies (including custom pricing units), returns the amount as is.
* This is specific to Metronome's API requirements.
*
* @param amount - The amount in currency to convert
* @param currency - The currency to convert
* @returns The amount in Metronome's integer format (cents for USD, base units for others)
* @example
* toMetronomeCurrency(1.23, 'usd') // returns 123 (cents)
* toMetronomeCurrency(1000, 'jpy') // returns 1000 (yen)
*/
export function toMetronomeCurrency(amount: number, currency: string): number {
if (currency === 'usd') {
return Math.round(amount * 100)
}
return amount
}
/**
* Converts Metronome's integer amount back to a formatted currency string.
* For USD, converts from cents to dollars.
* For all other currencies (including custom pricing units), returns the amount as is.
* This is specific to Metronome's API requirements.
*
* @param amount - The amount in Metronome's integer format (cents for USD, base units for others)
* @param currency - The currency to convert
* @returns The formatted amount in currency with 2 decimal places for USD
* @example
* formatMetronomeCurrency(123, 'usd') // returns "1.23" (cents to USD)
* formatMetronomeCurrency(1000, 'jpy') // returns "1000" (yen)
*/
export function formatMetronomeCurrency(
amount: number,
currency: string
): string {
if (currency === 'usd') {
return (amount / 100).toFixed(2)
}
return amount.toString()
}
/**
* Converts a USD amount to microdollars (1/1,000,000 of a dollar).
* This conversion is commonly used in financial systems to avoid floating-point precision issues