[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(() => {