From 34b1fd5a72b98df37869cf06f83f73f3e92cbbf8 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Fri, 9 May 2025 10:42:03 -0700 Subject: [PATCH] [API Node] Allow authentification via Comfy API key (#3815) Co-authored-by: github-actions --- .../dialog/content/SignInContent.vue | 217 +++++++++++------- .../dialog/content/setting/UserPanel.vue | 30 +++ .../dialog/content/signin/ApiKeyForm.test.ts | 114 +++++++++ .../dialog/content/signin/ApiKeyForm.vue | 111 +++++++++ src/locales/en/main.json | 21 ++ src/locales/es/main.json | 21 ++ src/locales/fr/main.json | 21 ++ src/locales/ja/main.json | 21 ++ src/locales/ko/main.json | 21 ++ src/locales/ru/main.json | 21 ++ src/locales/zh/main.json | 21 ++ src/schemas/signInSchema.ts | 10 + src/scripts/api.ts | 20 ++ src/scripts/app.ts | 4 + src/stores/apiKeyAuthStore.ts | 69 ++++++ src/stores/firebaseAuthStore.ts | 69 ++++-- src/types/authTypes.ts | 9 + 17 files changed, 692 insertions(+), 108 deletions(-) create mode 100644 src/components/dialog/content/signin/ApiKeyForm.test.ts create mode 100644 src/components/dialog/content/signin/ApiKeyForm.vue create mode 100644 src/stores/apiKeyAuthStore.ts create mode 100644 src/types/authTypes.ts diff --git a/src/components/dialog/content/SignInContent.vue b/src/components/dialog/content/SignInContent.vue index 9501531d7..70377d07e 100644 --- a/src/components/dialog/content/SignInContent.vue +++ b/src/components/dialog/content/SignInContent.vue @@ -1,95 +1,132 @@ @@ -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 () => { diff --git a/src/components/dialog/content/setting/UserPanel.vue b/src/components/dialog/content/setting/UserPanel.vue index 9047d8c5a..afe950eca 100644 --- a/src/components/dialog/content/setting/UserPanel.vue +++ b/src/components/dialog/content/setting/UserPanel.vue @@ -4,6 +4,7 @@

{{ $t('userSettings.title') }}

+
+ +
+
+

+ {{ $t('auth.apiKey.title') }} +

+
+ + {{ $t('auth.apiKey.label') }} +
+
+ +
+

@@ -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') } diff --git a/src/components/dialog/content/signin/ApiKeyForm.test.ts b/src/components/dialog/content/signin/ApiKeyForm.test.ts new file mode 100644 index 000000000..65c62268e --- /dev/null +++ b/src/components/dialog/content/signin/ApiKeyForm.test.ts @@ -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' + ) + }) +}) diff --git a/src/components/dialog/content/signin/ApiKeyForm.vue b/src/components/dialog/content/signin/ApiKeyForm.vue new file mode 100644 index 000000000..11dd0866a --- /dev/null +++ b/src/components/dialog/content/signin/ApiKeyForm.vue @@ -0,0 +1,111 @@ + + + diff --git a/src/locales/en/main.json b/src/locales/en/main.json index bcd671c23..84b85d336 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -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", diff --git a/src/locales/es/main.json b/src/locales/es/main.json index eb85678f7..d2b3a731b 100644 --- a/src/locales/es/main.json +++ b/src/locales/es/main.json @@ -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": { diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json index 187b15672..6d3bfb8a2 100644 --- a/src/locales/fr/main.json +++ b/src/locales/fr/main.json @@ -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 l’enregistrement 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": { diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json index a403909f8..6c1d330f0 100644 --- a/src/locales/ja/main.json +++ b/src/locales/ja/main.json @@ -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": { diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json index f68c00c6d..2d5d896b4 100644 --- a/src/locales/ko/main.json +++ b/src/locales/ko/main.json @@ -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": { diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json index 065a5ce9a..6a468fa15 100644 --- a/src/locales/ru/main.json +++ b/src/locales/ru/main.json @@ -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": { diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json index 9999ee729..6210b1886 100644 --- a/src/locales/zh/main.json +++ b/src/locales/zh/main.json @@ -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": { diff --git a/src/schemas/signInSchema.ts b/src/schemas/signInSchema.ts index bb66cd093..0e1e4905d 100644 --- a/src/schemas/signInSchema.ts +++ b/src/schemas/signInSchema.ts @@ -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 + export const signInSchema = z.object({ email: z .string() diff --git a/src/scripts/api.ts b/src/scripts/api.ts index 98f7b4871..ba6cb569e 100644 --- a/src/scripts/api.ts +++ b/src/scripts/api.ts @@ -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 } } } diff --git a/src/scripts/app.ts b/src/scripts/app.ts index d4f8ec4c6..7f4dceb4e 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -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) diff --git a/src/stores/apiKeyAuthStore.ts b/src/stores/apiKeyAuthStore.ts new file mode 100644 index 000000000..14f1c02fa --- /dev/null +++ b/src/stores/apiKeyAuthStore.ts @@ -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(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 + } +}) diff --git a/src/stores/firebaseAuthStore.ts b/src/stores/firebaseAuthStore.ts index 9fedc19b6..bc4365250 100644 --- a/src/stores/firebaseAuthStore.ts +++ b/src/stores/firebaseAuthStore.ts @@ -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} + * - 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 => { + const token = await getIdToken() + if (token) { + return { + Authorization: `Bearer ${token}` + } + } + + const apiKeyStore = useApiKeyAuthStore() + return apiKeyStore.getAuthHeader() + } + const fetchBalance = async (): Promise => { 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 => { + const createCustomer = async (): Promise => { + 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 => { - 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 => { - 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 } }) diff --git a/src/types/authTypes.ts b/src/types/authTypes.ts new file mode 100644 index 000000000..07bb43dd6 --- /dev/null +++ b/src/types/authTypes.ts @@ -0,0 +1,9 @@ +type LoggedInAuthHeader = { + Authorization: `Bearer ${string}` +} + +export type ApiKeyAuthHeader = { + 'X-API-KEY': string +} + +export type AuthHeader = LoggedInAuthHeader | ApiKeyAuthHeader