[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

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