[API Node] Allow authentification via Comfy API key (#3815)

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Christian Byrne
2025-05-09 10:42:03 -07:00
committed by GitHub
parent aa46524829
commit 34b1fd5a72
17 changed files with 692 additions and 108 deletions

View File

@@ -1,95 +1,132 @@
<template>
<div class="w-96 p-2">
<!-- Header -->
<div class="flex flex-col gap-4 mb-8">
<h1 class="text-2xl font-medium leading-normal my-0">
{{ isSignIn ? t('auth.login.title') : t('auth.signup.title') }}
</h1>
<p class="text-base my-0">
<span class="text-muted">{{
isSignIn
? t('auth.login.newUser')
: t('auth.signup.alreadyHaveAccount')
}}</span>
<span class="ml-1 cursor-pointer text-blue-500" @click="toggleState">{{
isSignIn ? t('auth.login.signUp') : t('auth.signup.signIn')
}}</span>
</p>
</div>
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
{{ t('auth.login.insecureContextWarning') }}
</Message>
<!-- Form -->
<SignInForm v-if="isSignIn" @submit="signInWithEmail" />
<div class="w-96 p-2 overflow-x-hidden">
<ApiKeyForm
v-if="showApiKeyForm"
@back="showApiKeyForm = false"
@success="onSuccess"
/>
<template v-else>
<Message v-if="userIsInChina" severity="warn" class="mb-4">
{{ t('auth.signup.regionRestrictionChina') }}
<!-- Header -->
<div class="flex flex-col gap-4 mb-8">
<h1 class="text-2xl font-medium leading-normal my-0">
{{ isSignIn ? t('auth.login.title') : t('auth.signup.title') }}
</h1>
<p class="text-base my-0">
<span class="text-muted">{{
isSignIn
? t('auth.login.newUser')
: t('auth.signup.alreadyHaveAccount')
}}</span>
<span
class="ml-1 cursor-pointer text-blue-500"
@click="toggleState"
>{{
isSignIn ? t('auth.login.signUp') : t('auth.signup.signIn')
}}</span
>
</p>
</div>
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
{{ t('auth.login.insecureContextWarning') }}
</Message>
<SignUpForm v-else @submit="signUpWithEmail" />
<!-- Form -->
<SignInForm v-if="isSignIn" @submit="signInWithEmail" />
<template v-else>
<Message v-if="userIsInChina" severity="warn" class="mb-4">
{{ t('auth.signup.regionRestrictionChina') }}
</Message>
<SignUpForm v-else @submit="signUpWithEmail" />
</template>
<!-- Divider -->
<Divider align="center" layout="horizontal" class="my-8">
<span class="text-muted">{{ t('auth.login.orContinueWith') }}</span>
</Divider>
<!-- Social Login Buttons -->
<div class="flex flex-col gap-6">
<Button
type="button"
class="h-10"
severity="secondary"
outlined
@click="signInWithGoogle"
>
<i class="pi pi-google mr-2"></i>
{{
isSignIn
? t('auth.login.loginWithGoogle')
: t('auth.signup.signUpWithGoogle')
}}
</Button>
<Button
type="button"
class="h-10"
severity="secondary"
outlined
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
{{
isSignIn
? t('auth.login.loginWithGithub')
: t('auth.signup.signUpWithGithub')
}}
</Button>
<Button
type="button"
class="h-10"
severity="secondary"
outlined
@click="showApiKeyForm = true"
>
<img
src="/assets/images/comfy-logo-mono.svg"
class="w-5 h-5 mr-2"
alt="Comfy"
/>
{{ t('auth.login.useApiKey') }}
</Button>
<small class="text-muted text-center">
{{ t('auth.apiKey.helpText') }}
<a
href="https://platform.comfy.org/login"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.apiKey.generateKey') }}
</a>
</small>
</div>
<!-- Terms & Contact -->
<p class="text-xs text-muted mt-8">
{{ t('auth.login.termsText') }}
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.login.termsLink') }}
</a>
{{ t('auth.login.andText') }}
<a
href="https://www.comfy.org/privacy"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.login.privacyLink') }} </a
>.
{{ t('auth.login.questionsContactPrefix') }}
<a href="mailto:hello@comfy.org" class="text-blue-500 cursor-pointer">
hello@comfy.org</a
>.
</p>
</template>
<!-- Divider -->
<Divider align="center" layout="horizontal" class="my-8">
<span class="text-muted">{{ t('auth.login.orContinueWith') }}</span>
</Divider>
<!-- Social Login Buttons -->
<div class="flex flex-col gap-6">
<Button
type="button"
class="h-10"
severity="secondary"
outlined
@click="signInWithGoogle"
>
<i class="pi pi-google mr-2"></i>
{{
isSignIn
? t('auth.login.loginWithGoogle')
: t('auth.signup.signUpWithGoogle')
}}
</Button>
<Button
type="button"
class="h-10"
severity="secondary"
outlined
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
{{
isSignIn
? t('auth.login.loginWithGithub')
: t('auth.signup.signUpWithGithub')
}}
</Button>
</div>
<!-- Terms & Contact -->
<p class="text-xs text-muted mt-8">
{{ t('auth.login.termsText') }}
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.login.termsLink') }}
</a>
{{ t('auth.login.andText') }}
<a
href="https://www.comfy.org/privacy"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.login.privacyLink') }} </a
>.
{{ t('auth.login.questionsContactPrefix') }}
<a href="mailto:hello@comfy.org" class="text-blue-500 cursor-pointer">
hello@comfy.org</a
>.
</p>
</div>
</template>
@@ -104,6 +141,7 @@ import { SignInData, SignUpData } from '@/schemas/signInSchema'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
import { isInChina } from '@/utils/networkUtil'
import ApiKeyForm from './signin/ApiKeyForm.vue'
import SignInForm from './signin/SignInForm.vue'
import SignUpForm from './signin/SignUpForm.vue'
@@ -115,8 +153,11 @@ const { t } = useI18n()
const authService = useFirebaseAuthService()
const isSecureContext = window.isSecureContext
const isSignIn = ref(true)
const showApiKeyForm = ref(false)
const toggleState = () => {
isSignIn.value = !isSignIn.value
showApiKeyForm.value = false
}
const signInWithGoogle = async () => {

View File

@@ -4,6 +4,7 @@
<h2 class="text-2xl font-bold mb-2">{{ $t('userSettings.title') }}</h2>
<Divider class="mb-3" />
<!-- Normal User Panel -->
<div v-if="user" class="flex flex-col gap-2">
<UserAvatar
v-if="user.photoURL"
@@ -66,6 +67,27 @@
/>
</div>
<!-- API Key Panel -->
<div v-else-if="hasApiKey" class="flex flex-col gap-4">
<div class="flex flex-col gap-0.5">
<h3 class="font-medium">
{{ $t('auth.apiKey.title') }}
</h3>
<div class="text-muted flex items-center gap-1">
<i class="pi pi-key" />
{{ $t('auth.apiKey.label') }}
</div>
</div>
<Button
class="mt-4 w-32"
severity="secondary"
:label="$t('auth.signOut.signOut')"
icon="pi pi-sign-out"
@click="handleApiKeySignOut"
/>
</div>
<!-- Login Section -->
<div v-else class="flex flex-col gap-4">
<p class="text-gray-600">
@@ -94,14 +116,18 @@ import { computed } from 'vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import { useDialogService } from '@/services/dialogService'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useCommandStore } from '@/stores/commandStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const dialogService = useDialogService()
const authStore = useFirebaseAuthStore()
const commandStore = useCommandStore()
const apiKeyStore = useApiKeyAuthStore()
const user = computed(() => authStore.currentUser)
const loading = computed(() => authStore.loading)
const hasApiKey = computed(() => apiKeyStore.hasApiKey)
const providerName = computed(() => {
const providerId = user.value?.providerData[0]?.providerId
@@ -134,6 +160,10 @@ const handleSignOut = async () => {
await commandStore.execute('Comfy.User.SignOut')
}
const handleApiKeySignOut = async () => {
await apiKeyStore.clearStoredApiKey()
}
const handleSignIn = async () => {
await commandStore.execute('Comfy.User.OpenSignInDialog')
}

View File

@@ -0,0 +1,114 @@
import { Form } from '@primevue/forms'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import ApiKeyForm from './ApiKeyForm.vue'
const mockStoreApiKey = vi.fn()
const mockLoading = vi.fn(() => false)
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
loading: mockLoading()
}))
}))
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: vi.fn(() => ({
storeApiKey: mockStoreApiKey
}))
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
auth: {
apiKey: {
title: 'API Key',
label: 'API Key',
placeholder: 'Enter your API Key',
error: 'Invalid API Key',
helpText: 'Need an API key?',
generateKey: 'Get one here',
whitelistInfo: 'About non-whitelisted sites'
}
},
g: {
back: 'Back',
save: 'Save',
learnMore: 'Learn more'
}
}
}
})
describe('ApiKeyForm', () => {
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)
vi.clearAllMocks()
mockStoreApiKey.mockReset()
mockLoading.mockReset()
})
const mountComponent = (props: any = {}) => {
return mount(ApiKeyForm, {
global: {
plugins: [PrimeVue, createPinia(), i18n],
components: { Button, Form, InputText, Message }
},
props
})
}
it('renders correctly with all required elements', () => {
const wrapper = mountComponent()
expect(wrapper.find('h1').text()).toBe('API Key')
expect(wrapper.find('label').text()).toBe('API Key')
expect(wrapper.findComponent(InputText).exists()).toBe(true)
expect(wrapper.findComponent(Button).exists()).toBe(true)
})
it('emits back event when back button is clicked', async () => {
const wrapper = mountComponent()
await wrapper.findComponent(Button).trigger('click')
expect(wrapper.emitted('back')).toBeTruthy()
})
it('shows loading state when submitting', async () => {
mockLoading.mockReturnValue(true)
const wrapper = mountComponent()
const input = wrapper.findComponent(InputText)
await input.setValue(
'comfyui-123456789012345678901234567890123456789012345678901234567890123456789012'
)
await wrapper.find('form').trigger('submit')
const submitButton = wrapper
.findAllComponents(Button)
.find((btn) => btn.text() === 'Save')
expect(submitButton?.props('loading')).toBe(true)
})
it('displays help text and links correctly', () => {
const wrapper = mountComponent()
const helpText = wrapper.find('small')
expect(helpText.text()).toContain('Need an API key?')
expect(helpText.find('a').attributes('href')).toBe(
'https://platform.comfy.org/login'
)
})
})

View File

@@ -0,0 +1,111 @@
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-4 mb-8">
<h1 class="text-2xl font-medium leading-normal my-0">
{{ t('auth.apiKey.title') }}
</h1>
<div class="flex flex-col gap-2">
<p class="text-base my-0 text-muted">
{{ t('auth.apiKey.description') }}
</p>
<a
href="https://docs.comfy.org/interface/user#logging-in-with-an-api-key"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('g.learnMore') }}
</a>
</div>
</div>
<Form
v-slot="$form"
class="flex flex-col gap-6"
:resolver="zodResolver(apiKeySchema)"
@submit="onSubmit"
>
<Message v-if="$form.apiKey?.invalid" severity="error" class="mb-4">
{{ $form.apiKey.error.message }}
</Message>
<div class="flex flex-col gap-2">
<label
class="opacity-80 text-base font-medium mb-2"
for="comfy-org-api-key"
>
{{ t('auth.apiKey.label') }}
</label>
<div class="flex flex-col gap-2">
<InputText
pt:root:id="comfy-org-api-key"
pt:root:autocomplete="off"
class="h-10"
name="apiKey"
type="password"
:placeholder="t('auth.apiKey.placeholder')"
:invalid="$form.apiKey?.invalid"
/>
<small class="text-muted">
{{ t('auth.apiKey.helpText') }}
<a
href="https://platform.comfy.org/login"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.apiKey.generateKey') }}
</a>
<span class="mx-1"></span>
<a
href="https://docs.comfy.org/tutorials/api-nodes/overview#log-in-with-api-key-on-non-whitelisted-websites"
target="_blank"
class="text-blue-500 cursor-pointer"
>
{{ t('auth.apiKey.whitelistInfo') }}
</a>
</small>
</div>
</div>
<div class="flex justify-between items-center mt-4">
<Button type="button" link @click="$emit('back')">
{{ t('g.back') }}
</Button>
<Button type="submit" :loading="loading" :disabled="loading">
{{ t('g.save') }}
</Button>
</div>
</Form>
</div>
</template>
<script setup lang="ts">
import { Form, FormSubmitEvent } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { apiKeySchema } from '@/schemas/signInSchema'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const authStore = useFirebaseAuthStore()
const apiKeyStore = useApiKeyAuthStore()
const loading = computed(() => authStore.loading)
const { t } = useI18n()
const emit = defineEmits<{
(e: 'back'): void
(e: 'success'): void
}>()
const onSubmit = async (event: FormSubmitEvent) => {
if (event.valid) {
await apiKeyStore.storeApiKey(event.values.apiKey)
emit('success')
}
}
</script>

View File

@@ -1232,8 +1232,27 @@
"unauthorizedDomain": "Your domain {domain} is not authorized to use this service. Please contact {email} to add your domain to the whitelist."
},
"auth": {
"apiKey": {
"title": "API Key",
"label": "API Key",
"description": "Use your Comfy API key to enable API Nodes",
"placeholder": "Enter your API Key",
"error": "Invalid API Key",
"storageFailed": "Failed to store API Key",
"storageFailedDetail": "Please try again.",
"stored": "API Key stored",
"storedDetail": "Your API Key has been stored successfully",
"cleared": "API Key cleared",
"clearedDetail": "Your API Key has been cleared successfully",
"invalid": "Invalid API Key",
"invalidDetail": "Please enter a valid API Key",
"helpText": "Need an API key?",
"generateKey": "Get one here",
"whitelistInfo": "About non-whitelisted sites"
},
"login": {
"title": "Log in to your account",
"useApiKey": "Comfy API Key",
"signInOrSignUp": "Sign In / Sign Up",
"forgotPasswordError": "Failed to send password reset email",
"passwordResetSent": "Password reset email sent",
@@ -1290,6 +1309,8 @@
"required": "Required",
"minLength": "Must be at least {length} characters",
"maxLength": "Must be no more than {length} characters",
"prefix": "Must start with {prefix}",
"length": "Must be {length} characters",
"password": {
"requirements": "Password requirements",
"minLength": "Must be between 8 and 32 characters",

View File

@@ -29,6 +29,24 @@
"title": "Se requiere iniciar sesión para usar los nodos de API"
},
"auth": {
"apiKey": {
"cleared": "Clave API eliminada",
"clearedDetail": "Tu clave API se ha eliminado correctamente",
"description": "Usa tu clave API de Comfy para habilitar los nodos de API",
"error": "Clave API no válida",
"generateKey": "Consíguela aquí",
"helpText": "¿Necesitas una clave API?",
"invalid": "Clave API no válida",
"invalidDetail": "Por favor, introduce una clave API válida",
"label": "Clave API",
"placeholder": "Introduce tu clave API",
"storageFailed": "No se pudo guardar la clave API",
"storageFailedDetail": "Por favor, inténtalo de nuevo.",
"stored": "Clave API guardada",
"storedDetail": "Tu clave API se ha guardado correctamente",
"title": "Clave API",
"whitelistInfo": "Acerca de los sitios no incluidos en la lista blanca"
},
"login": {
"andText": "y",
"confirmPasswordLabel": "Confirmar contraseña",
@@ -56,6 +74,7 @@
"termsLink": "Términos de uso",
"termsText": "Al hacer clic en \"Siguiente\" o \"Registrarse\", aceptas nuestros",
"title": "Inicia sesión en tu cuenta",
"useApiKey": "Clave API de Comfy",
"userAvatar": "Avatar de usuario"
},
"passwordUpdate": {
@@ -1330,6 +1349,7 @@
},
"validation": {
"invalidEmail": "Dirección de correo electrónico inválida",
"length": "Debe tener {length} caracteres",
"maxLength": "No debe tener más de {length} caracteres",
"minLength": "Debe tener al menos {length} caracteres",
"password": {
@@ -1342,6 +1362,7 @@
"uppercase": "Debe contener al menos una letra mayúscula"
},
"personalDataConsentRequired": "Debes aceptar el procesamiento de tus datos personales.",
"prefix": "Debe comenzar con {prefix}",
"required": "Requerido"
},
"welcome": {

View File

@@ -29,6 +29,24 @@
"title": "Connexion requise pour utiliser les nœuds API"
},
"auth": {
"apiKey": {
"cleared": "Clé API supprimée",
"clearedDetail": "Votre clé API a été supprimée avec succès",
"description": "Utilisez votre clé API Comfy pour activer les nœuds API",
"error": "Clé API invalide",
"generateKey": "Obtenez-en une ici",
"helpText": "Besoin d'une clé API ?",
"invalid": "Clé API invalide",
"invalidDetail": "Veuillez entrer une clé API valide",
"label": "Clé API",
"placeholder": "Entrez votre clé API",
"storageFailed": "Échec de lenregistrement de la clé API",
"storageFailedDetail": "Veuillez réessayer.",
"stored": "Clé API enregistrée",
"storedDetail": "Votre clé API a été enregistrée avec succès",
"title": "Clé API",
"whitelistInfo": "À propos des sites non autorisés"
},
"login": {
"andText": "et",
"confirmPasswordLabel": "Confirmer le mot de passe",
@@ -56,6 +74,7 @@
"termsLink": "Conditions d'utilisation",
"termsText": "En cliquant sur \"Suivant\" ou \"S'inscrire\", vous acceptez nos",
"title": "Connectez-vous à votre compte",
"useApiKey": "Clé API Comfy",
"userAvatar": "Avatar utilisateur"
},
"passwordUpdate": {
@@ -1330,6 +1349,7 @@
},
"validation": {
"invalidEmail": "Adresse e-mail invalide",
"length": "Doit comporter {length} caractères",
"maxLength": "Ne doit pas dépasser {length} caractères",
"minLength": "Doit contenir au moins {length} caractères",
"password": {
@@ -1342,6 +1362,7 @@
"uppercase": "Doit contenir au moins une lettre majuscule"
},
"personalDataConsentRequired": "Vous devez accepter le traitement de vos données personnelles.",
"prefix": "Doit commencer par {prefix}",
"required": "Requis"
},
"welcome": {

View File

@@ -29,6 +29,24 @@
"title": "APIードを使用するためにはサインインが必要です"
},
"auth": {
"apiKey": {
"cleared": "APIキーが削除されました",
"clearedDetail": "APIキーが正常に削除されました",
"description": "Comfy APIキーを使用してAPIードを有効にします",
"error": "無効なAPIキーです",
"generateKey": "こちらから取得",
"helpText": "APIキーが必要ですか",
"invalid": "無効なAPIキーです",
"invalidDetail": "有効なAPIキーを入力してください",
"label": "APIキー",
"placeholder": "APIキーを入力してください",
"storageFailed": "APIキーの保存に失敗しました",
"storageFailedDetail": "もう一度お試しください。",
"stored": "APIキーが保存されました",
"storedDetail": "APIキーが正常に保存されました",
"title": "APIキー",
"whitelistInfo": "ホワイトリストに登録されていないサイトについて"
},
"login": {
"andText": "および",
"confirmPasswordLabel": "パスワードの確認",
@@ -56,6 +74,7 @@
"termsLink": "利用規約",
"termsText": "「次へ」または「サインアップ」をクリックすると、私たちの",
"title": "アカウントにログインする",
"useApiKey": "Comfy APIキー",
"userAvatar": "ユーザーアバター"
},
"passwordUpdate": {
@@ -1330,6 +1349,7 @@
},
"validation": {
"invalidEmail": "無効なメールアドレス",
"length": "{length}文字でなければなりません",
"maxLength": "{length}文字以下でなければなりません",
"minLength": "{length}文字以上でなければなりません",
"password": {
@@ -1342,6 +1362,7 @@
"uppercase": "少なくとも1つの大文字を含む必要があります"
},
"personalDataConsentRequired": "個人データの処理に同意する必要があります。",
"prefix": "{prefix}で始める必要があります",
"required": "必須"
},
"welcome": {

View File

@@ -29,6 +29,24 @@
"title": "API 노드 사용에 필요한 로그인"
},
"auth": {
"apiKey": {
"cleared": "API 키 삭제됨",
"clearedDetail": "API 키가 성공적으로 삭제되었습니다",
"description": "Comfy API 키를 사용하여 API 노드를 활성화하세요",
"error": "유효하지 않은 API 키",
"generateKey": "여기에서 받기",
"helpText": "API 키가 필요하신가요?",
"invalid": "유효하지 않은 API 키",
"invalidDetail": "유효한 API 키를 입력해 주세요",
"label": "API 키",
"placeholder": "API 키를 입력하세요",
"storageFailed": "API 키 저장 실패",
"storageFailedDetail": "다시 시도해 주세요.",
"stored": "API 키 저장됨",
"storedDetail": "API 키가 성공적으로 저장되었습니다",
"title": "API 키",
"whitelistInfo": "비허용 사이트에 대하여"
},
"login": {
"andText": "및",
"confirmPasswordLabel": "비밀번호 확인",
@@ -56,6 +74,7 @@
"termsLink": "이용 약관",
"termsText": "\"다음\" 또는 \"가입하기\"를 클릭하면 우리의",
"title": "계정에 로그인",
"useApiKey": "Comfy API 키",
"userAvatar": "사용자 아바타"
},
"passwordUpdate": {
@@ -1330,6 +1349,7 @@
},
"validation": {
"invalidEmail": "유효하지 않은 이메일 주소",
"length": "{length}자여야 합니다",
"maxLength": "{length}자를 초과할 수 없습니다",
"minLength": "{length}자 이상이어야 합니다",
"password": {
@@ -1342,6 +1362,7 @@
"uppercase": "적어도 하나의 대문자를 포함해야 합니다"
},
"personalDataConsentRequired": "개인 데이터 처리에 동의해야 합니다.",
"prefix": "{prefix}(으)로 시작해야 합니다",
"required": "필수"
},
"welcome": {

View File

@@ -29,6 +29,24 @@
"title": "Требуется вход для использования API Nodes"
},
"auth": {
"apiKey": {
"cleared": "API-ключ удалён",
"clearedDetail": "Ваш API-ключ был успешно удалён",
"description": "Используйте ваш Comfy API-ключ для активации API-узлов",
"error": "Недействительный API-ключ",
"generateKey": "Получить здесь",
"helpText": "Нужен API-ключ?",
"invalid": "Недействительный API-ключ",
"invalidDetail": "Пожалуйста, введите действительный API-ключ",
"label": "API-ключ",
"placeholder": "Введите ваш API-ключ",
"storageFailed": "Не удалось сохранить API-ключ",
"storageFailedDetail": "Пожалуйста, попробуйте еще раз.",
"stored": "API-ключ сохранён",
"storedDetail": "Ваш API-ключ был успешно сохранён",
"title": "API-ключ",
"whitelistInfo": "О не включённых в белый список сайтах"
},
"login": {
"andText": "и",
"confirmPasswordLabel": "Подтвердите пароль",
@@ -56,6 +74,7 @@
"termsLink": "Условиями использования",
"termsText": "Нажимая \"Далее\" или \"Зарегистрироваться\", вы соглашаетесь с нашими",
"title": "Войдите в свой аккаунт",
"useApiKey": "Comfy API-ключ",
"userAvatar": "Аватар пользователя"
},
"passwordUpdate": {
@@ -1330,6 +1349,7 @@
},
"validation": {
"invalidEmail": "Недействительный адрес электронной почты",
"length": "Должно быть {length} символов",
"maxLength": "Должно быть не более {length} символов",
"minLength": "Должно быть не менее {length} символов",
"password": {
@@ -1342,6 +1362,7 @@
"uppercase": "Должен содержать хотя бы одну заглавную букву"
},
"personalDataConsentRequired": "Вы должны согласиться на обработку ваших персональных данных.",
"prefix": "Должно начинаться с {prefix}",
"required": "Обязательно"
},
"welcome": {

View File

@@ -29,6 +29,24 @@
"title": "使用API节点需要登录"
},
"auth": {
"apiKey": {
"cleared": "API 密钥已清除",
"clearedDetail": "您的 API 密钥已成功清除",
"description": "使用您的 Comfy API 密钥以启用 API 节点",
"error": "无效的 API 密钥",
"generateKey": "在这里获取",
"helpText": "需要 API 密钥?",
"invalid": "无效的 API 密钥",
"invalidDetail": "请输入有效的 API 密钥",
"label": "API 密钥",
"placeholder": "请输入您的 API 密钥",
"storageFailed": "API 密钥存储失败",
"storageFailedDetail": "请重试。",
"stored": "API 密钥已存储",
"storedDetail": "您的 API 密钥已成功存储",
"title": "API 密钥",
"whitelistInfo": "关于非白名单网站"
},
"login": {
"andText": "和",
"confirmPasswordLabel": "确认密码",
@@ -56,6 +74,7 @@
"termsLink": "使用条款",
"termsText": "点击“下一步”或“注册”即表示您同意我们的",
"title": "登录您的账户",
"useApiKey": "Comfy API 密钥",
"userAvatar": "用户头像"
},
"passwordUpdate": {
@@ -1330,6 +1349,7 @@
},
"validation": {
"invalidEmail": "无效的电子邮件地址",
"length": "必须为{length}个字符",
"maxLength": "不能超过{length}个字符",
"minLength": "必须至少有{length}个字符",
"password": {
@@ -1342,6 +1362,7 @@
"uppercase": "必须包含至少一个大写字母"
},
"personalDataConsentRequired": "您必须同意处理您的个人数据。",
"prefix": "必须以 {prefix} 开头",
"required": "必填"
},
"welcome": {

View File

@@ -2,6 +2,16 @@ import { z } from 'zod'
import { t } from '@/i18n'
export const apiKeySchema = z.object({
apiKey: z
.string()
.trim()
.startsWith('comfyui-', t('validation.prefix', { prefix: 'comfyui-' }))
.length(72, t('validation.length', { length: 72 }))
})
export type ApiKeyData = z.infer<typeof apiKeySchema>
export const signInSchema = z.object({
email: z
.string()

View File

@@ -58,6 +58,21 @@ interface QueuePromptRequestBody {
* ```
*/
auth_token_comfy_org?: string
/**
* The auth token for the comfy org account if the user is logged in.
*
* Backend node can access this token by specifying following input:
* ```python
* def INPUT_TYPES(s):
* return {
* "hidden": { "api_key": "API_KEY_COMFY_ORG" }
* }
*
* def execute(self, api_key: str):
* print(f"API Key: {api_key}")
* ```
*/
api_key_comfy_org?: string
}
front?: boolean
number?: number
@@ -228,6 +243,10 @@ export class ComfyApi extends EventTarget {
* custom nodes are patched.
*/
authToken?: string
/**
* The API key for the comfy org account if the user logged in via API key.
*/
apiKey?: string
constructor() {
super()
@@ -545,6 +564,7 @@ export class ComfyApi extends EventTarget {
prompt,
extra_data: {
auth_token_comfy_org: this.authToken,
api_key_comfy_org: this.apiKey,
extra_pnginfo: { workflow }
}
}

View File

@@ -36,6 +36,7 @@ import { useDialogService } from '@/services/dialogService'
import { useExtensionService } from '@/services/extensionService'
import { useLitegraphService } from '@/services/litegraphService'
import { useWorkflowService } from '@/services/workflowService'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExtensionStore } from '@/stores/extensionStore'
@@ -1186,6 +1187,7 @@ export class ComfyApp {
let comfyOrgAuthToken =
(await useFirebaseAuthStore().getIdToken()) ?? undefined
let comfyOrgApiKey = useApiKeyAuthStore().getApiKey()
try {
while (this.#queueItems.length) {
@@ -1199,8 +1201,10 @@ export class ComfyApp {
const p = await this.graphToPrompt(this.graph, { queueNodeIds })
try {
api.authToken = comfyOrgAuthToken
api.apiKey = comfyOrgApiKey ?? undefined
const res = await api.queuePrompt(number, p)
delete api.authToken
delete api.apiKey
executionStore.lastNodeErrors = res.node_errors ?? null
if (executionStore.lastNodeErrors?.length) {
this.canvas.draw(true, true)

View File

@@ -0,0 +1,69 @@
import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import { useToastStore } from '@/stores/toastStore'
const STORAGE_KEY = 'comfy_api_key'
export const useApiKeyAuthStore = defineStore('apiKeyAuth', () => {
const apiKey = useLocalStorage<string | null>(STORAGE_KEY, null)
const toastStore = useToastStore()
const { wrapWithErrorHandlingAsync, toastErrorHandler } = useErrorHandling()
const reportError = (error: unknown) => {
if (error instanceof Error && error.message === 'STORAGE_FAILED') {
toastStore.add({
severity: 'error',
summary: t('auth.apiKey.storageFailed'),
detail: t('auth.apiKey.storageFailedDetail')
})
} else {
toastErrorHandler(error)
}
}
const storeApiKey = wrapWithErrorHandlingAsync(async (newApiKey: string) => {
apiKey.value = newApiKey
toastStore.add({
severity: 'success',
summary: t('auth.apiKey.stored'),
detail: t('auth.apiKey.storedDetail'),
life: 5000
})
return true
}, reportError)
const clearStoredApiKey = wrapWithErrorHandlingAsync(async () => {
apiKey.value = null
toastStore.add({
severity: 'success',
summary: t('auth.apiKey.cleared'),
detail: t('auth.apiKey.clearedDetail'),
life: 5000
})
return true
}, reportError)
const getApiKey = () => apiKey.value
const getAuthHeader = () => {
const comfyOrgApiKey = getApiKey()
if (comfyOrgApiKey) {
return {
'X-API-KEY': comfyOrgApiKey
}
}
return null
}
return {
hasApiKey: computed(() => !!apiKey.value),
storeApiKey,
clearStoredApiKey,
getAuthHeader,
getApiKey
}
})

View File

@@ -20,6 +20,8 @@ import { useFirebaseAuth } from 'vuefire'
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import { t } from '@/i18n'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { type AuthHeader } from '@/types/authTypes'
import { operations } from '@/types/comfyRegistryTypes'
type CreditPurchaseResponse =
@@ -93,12 +95,34 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
return null
}
/**
* Retrieves the appropriate authentication header for API requests.
* Checks for authentication in the following order:
* 1. Firebase authentication token (if user is logged in)
* 2. API key (if stored in the browser's credential manager)
*
* @returns {Promise<AuthHeader | null>}
* - A LoggedInAuthHeader with Bearer token if Firebase authenticated
* - An ApiKeyAuthHeader with X-API-KEY if API key exists
* - null if neither authentication method is available
*/
const getAuthHeader = async (): Promise<AuthHeader | null> => {
const token = await getIdToken()
if (token) {
return {
Authorization: `Bearer ${token}`
}
}
const apiKeyStore = useApiKeyAuthStore()
return apiKeyStore.getAuthHeader()
}
const fetchBalance = async (): Promise<GetCustomerBalanceResponse | null> => {
isFetchingBalance.value = true
try {
const token = await getIdToken()
if (!token) {
isFetchingBalance.value = false
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(
t('toastMessages.userNotAuthenticated')
)
@@ -106,7 +130,8 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
const response = await fetch(`${COMFY_API_BASE_URL}/customers/balance`, {
headers: {
Authorization: `Bearer ${token}`
...authHeader,
'Content-Type': 'application/json'
}
})
@@ -133,14 +158,17 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
}
}
const createCustomer = async (
token: string
): Promise<CreateCustomerResponse> => {
const createCustomer = async (): Promise<CreateCustomerResponse> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const createCustomerRes = await fetch(`${COMFY_API_BASE_URL}/customers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
...authHeader,
'Content-Type': 'application/json'
}
})
if (!createCustomerRes.ok) {
@@ -181,7 +209,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
if (!token) {
throw new Error('Cannot create customer: User not authenticated')
}
await createCustomer(token)
await createCustomer()
}
return result
@@ -242,22 +270,22 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
const addCredits = async (
requestBodyContent: CreditPurchasePayload
): Promise<CreditPurchaseResponse> => {
const token = await getIdToken()
if (!token) {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
// Ensure customer was created during login/registration
if (!customerCreated.value) {
await createCustomer(token)
await createCustomer()
customerCreated.value = true
}
const response = await fetch(`${COMFY_API_BASE_URL}/customers/credit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
...authHeader,
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBodyContent)
})
@@ -282,16 +310,16 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
const accessBillingPortal = async (
requestBody?: AccessBillingPortalReqBody
): Promise<AccessBillingPortalResponse> => {
const token = await getIdToken()
if (!token) {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const response = await fetch(`${COMFY_API_BASE_URL}/customers/billing`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
...authHeader,
'Content-Type': 'application/json'
},
...(requestBody && {
body: JSON.stringify(requestBody)
@@ -335,6 +363,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
fetchBalance,
accessBillingPortal,
sendPasswordReset,
updatePassword: _updatePassword
updatePassword: _updatePassword,
getAuthHeader
}
})

9
src/types/authTypes.ts Normal file
View File

@@ -0,0 +1,9 @@
type LoggedInAuthHeader = {
Authorization: `Bearer ${string}`
}
export type ApiKeyAuthHeader = {
'X-API-KEY': string
}
export type AuthHeader = LoggedInAuthHeader | ApiKeyAuthHeader