Compare commits

...

8 Commits

Author SHA1 Message Date
Terry Jia
e6a98e3286 export generateUUID 2025-04-17 21:59:47 -04:00
Chenlei Hu
9ce3cccfd4 [API Nodes] Wire password login (#3469) 2025-04-15 19:41:22 -04:00
Chenlei Hu
9935b322f0 [API Nodes] Signin/Signup dialog (#3466)
Co-authored-by: github-actions <github-actions@github.com>
2025-04-15 17:37:53 -04:00
Comfy Org PR Bot
60dd242b23 [chore] Update Comfy Registry API types from comfy-api@b664e39 (#3465)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-04-15 17:16:02 -04:00
Christian Byrne
cec0dcbccd [Api Node] Firebase auth and user auth store (#3467) 2025-04-15 17:15:51 -04:00
Chenlei Hu
907632a250 Use bundler moduleResolution mode in tsconfig (#3464) 2025-04-15 11:17:07 -04:00
Comfy Org PR Bot
45c450cdb9 [chore] Update litegraph to 0.13.3 (#3463)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-04-15 11:03:32 -04:00
Chenlei Hu
ca85b2b144 [DevExperience] Add recommended extensions to .vscode (#3459) 2025-04-15 10:27:43 -04:00
24 changed files with 2619 additions and 161 deletions

View File

@@ -49,4 +49,6 @@ const additionalInstructions = `
7. Implement proper error handling
8. Follow Vue 3 style guide and naming conventions
9. Use Vite for fast development and building
10. Use vue-i18n in composition API for any string literals. Place new translation
entries in src/locales/en/main.json.
`;

25
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,25 @@
{
"recommendations": [
"austenc.tailwind-docs",
"bradlc.vscode-tailwindcss",
"davidanson.vscode-markdownlint",
"dbaeumer.vscode-eslint",
"eamodio.gitlens",
"esbenp.prettier-vscode",
"figma.figma-vscode-extension",
"github.vscode-github-actions",
"github.vscode-pull-request-github",
"hbenl.vscode-test-explorer",
"lokalise.i18n-ally",
"ms-playwright.playwright",
"vitest.explorer",
"vue.volar",
"sonarsource.sonarlint-vscode",
"deque-systems.vscode-axe-linter",
"kisstkondoros.vscode-codemetrics",
"donjayamanne.githistory",
"wix.vscode-import-cost",
"prograhammer.tslint-vue",
"antfu.vite"
]
}

1166
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -73,7 +73,7 @@
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.31",
"@comfyorg/litegraph": "^0.13.2",
"@comfyorg/litegraph": "^0.13.3",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -91,6 +91,7 @@
"algoliasearch": "^5.21.0",
"axios": "^1.8.2",
"dotenv": "^16.4.5",
"firebase": "^11.6.0",
"fuse.js": "^7.0.0",
"jsondiffpatch": "^0.6.0",
"lodash": "^4.17.21",
@@ -103,6 +104,7 @@
"vue": "^3.5.13",
"vue-i18n": "^9.14.3",
"vue-router": "^4.4.3",
"vuefire": "^3.2.1",
"zod": "^3.23.8",
"zod-validation-error": "^3.3.0"
}

View File

@@ -0,0 +1,124 @@
<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>
<!-- Form -->
<SignInForm v-if="isSignIn" @submit="signInWithEmail" />
<SignUpForm v-else @submit="signInWithEmail" />
<!-- 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 -->
<p class="text-xs text-muted mt-8">
{{ t('auth.login.termsText') }}
<span class="text-blue-500 cursor-pointer">{{
t('auth.login.termsLink')
}}</span>
{{ t('auth.login.andText') }}
<span class="text-blue-500 cursor-pointer">{{
t('auth.login.privacyLink')
}}</span
>.
</p>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { SignInData, SignUpData } from '@/schemas/signInSchema'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import SignInForm from './signin/SignInForm.vue'
import SignUpForm from './signin/SignUpForm.vue'
const { t } = useI18n()
const { onSuccess } = defineProps<{
onSuccess: () => void
}>()
const firebaseAuthStore = useFirebaseAuthStore()
const isSignIn = ref(true)
const toggleState = () => {
isSignIn.value = !isSignIn.value
}
const signInWithGoogle = () => {
// Implement Google login
console.log(isSignIn.value)
console.log('Google login clicked')
onSuccess()
}
const signInWithGithub = () => {
// Implement Github login
console.log(isSignIn.value)
console.log('Github login clicked')
onSuccess()
}
const signInWithEmail = async (values: SignInData | SignUpData) => {
const { email, password } = values
if (isSignIn.value) {
await firebaseAuthStore.login(email, password)
} else {
await firebaseAuthStore.register(email, password)
}
onSuccess()
}
</script>

View File

@@ -101,7 +101,6 @@
<script setup lang="ts">
import { Form, FormField, type FormSubmitEvent } from '@primevue/forms'
// @ts-expect-error https://github.com/primefaces/primevue/issues/6722
import { zodResolver } from '@primevue/forms/resolvers/zod'
import type { CaptureContext, User } from '@sentry/core'
import { captureMessage } from '@sentry/core'

View File

@@ -0,0 +1,89 @@
<template>
<Form
v-slot="$form"
class="flex flex-col gap-6"
:resolver="zodResolver(signInSchema)"
@submit="onSubmit"
>
<!-- Email Field -->
<div class="flex flex-col gap-2">
<label
class="opacity-80 text-base font-medium mb-2"
for="comfy-org-sign-in-email"
>
{{ t('auth.login.emailLabel') }}
</label>
<InputText
pt:root:id="comfy-org-sign-in-email"
pt:root:autocomplete="email"
class="h-10"
name="email"
type="text"
:placeholder="t('auth.login.emailPlaceholder')"
:invalid="$form.email?.invalid"
/>
<small v-if="$form.email?.invalid" class="text-red-500">{{
$form.email.error.message
}}</small>
</div>
<!-- Password Field -->
<div class="flex flex-col gap-2">
<div class="flex justify-between items-center mb-2">
<label
class="opacity-80 text-base font-medium"
for="comfy-org-sign-in-password"
>
{{ t('auth.login.passwordLabel') }}
</label>
<span class="text-muted text-base font-medium cursor-pointer">
{{ t('auth.login.forgotPassword') }}
</span>
</div>
<Password
input-id="comfy-org-sign-in-password"
pt:pc-input-text:root:autocomplete="current-password"
name="password"
:feedback="false"
toggle-mask
:placeholder="t('auth.login.passwordPlaceholder')"
:class="{ 'p-invalid': $form.password?.invalid }"
fluid
class="h-10"
/>
<small v-if="$form.password?.invalid" class="text-red-500">{{
$form.password.error.message
}}</small>
</div>
<!-- Submit Button -->
<Button
type="submit"
:label="t('auth.login.loginButton')"
class="h-10 font-medium mt-4"
/>
</Form>
</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 Password from 'primevue/password'
import { useI18n } from 'vue-i18n'
import { type SignInData, signInSchema } from '@/schemas/signInSchema'
const { t } = useI18n()
const emit = defineEmits<{
submit: [values: SignInData]
}>()
const onSubmit = (event: FormSubmitEvent) => {
if (event.valid) {
emit('submit', event.values as SignInData)
}
}
</script>

View File

@@ -0,0 +1,165 @@
<template>
<Form
v-slot="$form"
class="flex flex-col gap-6"
:resolver="zodResolver(signUpSchema)"
@submit="onSubmit"
>
<!-- Email Field -->
<div class="flex flex-col gap-2">
<label
class="opacity-80 text-base font-medium mb-2"
for="comfy-org-sign-up-email"
>
{{ t('auth.signup.emailLabel') }}
</label>
<InputText
pt:root:id="comfy-org-sign-up-email"
pt:root:autocomplete="email"
class="h-10"
name="email"
type="text"
:placeholder="t('auth.signup.emailPlaceholder')"
:invalid="$form.email?.invalid"
/>
<small v-if="$form.email?.invalid" class="text-red-500">{{
$form.email.error.message
}}</small>
</div>
<!-- Password Field -->
<div class="flex flex-col gap-2">
<div class="flex justify-between items-center mb-2">
<label
class="opacity-80 text-base font-medium"
for="comfy-org-sign-up-password"
>
{{ t('auth.signup.passwordLabel') }}
</label>
</div>
<Password
v-model="password"
input-id="comfy-org-sign-up-password"
pt:pc-input-text:root:autocomplete="new-password"
name="password"
:feedback="false"
toggle-mask
:placeholder="t('auth.signup.passwordPlaceholder')"
:class="{ 'p-invalid': $form.password?.invalid }"
fluid
class="h-10"
/>
<div class="flex flex-col gap-1">
<small
v-if="$form.password?.dirty || $form.password?.invalid"
class="text-sm"
>
{{ t('validation.password.requirements') }}:
<ul class="mt-1 space-y-1">
<li
:class="{
'text-red-500': !passwordChecks.length
}"
>
{{ t('validation.password.minLength') }}
</li>
<li
:class="{
'text-red-500': !passwordChecks.uppercase
}"
>
{{ t('validation.password.uppercase') }}
</li>
<li
:class="{
'text-red-500': !passwordChecks.lowercase
}"
>
{{ t('validation.password.lowercase') }}
</li>
<li
:class="{
'text-red-500': !passwordChecks.number
}"
>
{{ t('validation.password.number') }}
</li>
<li
:class="{
'text-red-500': !passwordChecks.special
}"
>
{{ t('validation.password.special') }}
</li>
</ul>
</small>
</div>
</div>
<!-- Confirm Password Field -->
<div class="flex flex-col gap-2">
<label
class="opacity-80 text-base font-medium mb-2"
for="comfy-org-sign-up-confirm-password"
>
{{ t('auth.login.confirmPasswordLabel') }}
</label>
<Password
name="confirmPassword"
input-id="comfy-org-sign-up-confirm-password"
pt:pc-input-text:root:autocomplete="new-password"
:feedback="false"
toggle-mask
:placeholder="t('auth.login.confirmPasswordPlaceholder')"
:class="{ 'p-invalid': $form.confirmPassword?.invalid }"
fluid
class="h-10"
/>
<small v-if="$form.confirmPassword?.error" class="text-red-500">{{
$form.confirmPassword.error.message
}}</small>
</div>
<!-- Submit Button -->
<Button
type="submit"
:label="t('auth.signup.signUpButton')"
class="h-10 font-medium mt-4"
/>
</Form>
</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 Password from 'primevue/password'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { type SignUpData, signUpSchema } from '@/schemas/signInSchema'
const { t } = useI18n()
const password = ref('')
// TODO: Use dynamic form to better organize the password checks.
// Ref: https://primevue.org/forms/#dynamic
const passwordChecks = computed(() => ({
length: password.value.length >= 8 && password.value.length <= 32,
uppercase: /[A-Z]/.test(password.value),
lowercase: /[a-z]/.test(password.value),
number: /\d/.test(password.value),
special: /[^A-Za-z0-9]/.test(password.value)
}))
const emit = defineEmits<{
submit: [values: SignUpData]
}>()
const onSubmit = (event: FormSubmitEvent) => {
if (event.valid) {
emit('submit', event.values as SignUpData)
}
}
</script>

12
src/config/firebase.ts Normal file
View File

@@ -0,0 +1,12 @@
import { FirebaseOptions } from 'firebase/app'
export const FIREBASE_CONFIG: FirebaseOptions = {
apiKey: 'AIzaSyC2-fomLqgCjb7ELwta1I9cEarPK8ziTGs',
authDomain: 'dreamboothy.firebaseapp.com',
databaseURL: 'https://dreamboothy-default-rtdb.firebaseio.com',
projectId: 'dreamboothy',
storageBucket: 'dreamboothy.appspot.com',
messagingSenderId: '357148958219',
appId: '1:357148958219:web:f5917f72e5f36a2015310e',
measurementId: 'G-3ZBD3MBTG4'
}

View File

@@ -1051,5 +1051,56 @@
"noTemplatesToExport": "No templates to export",
"failedToFetchLogs": "Failed to fetch server logs",
"migrateToLitegraphReroute": "Reroute nodes will be removed in future versions. Click to migrate to litegraph-native reroute."
},
"auth": {
"login": {
"title": "Log in to your account",
"newUser": "New here?",
"signUp": "Sign up",
"emailLabel": "Email",
"emailPlaceholder": "Enter your email",
"passwordLabel": "Password",
"passwordPlaceholder": "Enter your password",
"confirmPasswordLabel": "Confirm Password",
"confirmPasswordPlaceholder": "Enter the same password again",
"forgotPassword": "Forgot password?",
"loginButton": "Log in",
"orContinueWith": "Or continue with",
"loginWithGoogle": "Log in with Google",
"loginWithGithub": "Log in with Github",
"termsText": "By clicking \"Next\" or \"Sign Up\", you agree to our",
"termsLink": "Terms of Use",
"andText": "and",
"privacyLink": "Privacy Policy",
"success": "Login successful",
"failed": "Login failed"
},
"signup": {
"title": "Create an account",
"alreadyHaveAccount": "Already have an account?",
"emailLabel": "Email",
"emailPlaceholder": "Enter your email",
"passwordLabel": "Password",
"passwordPlaceholder": "Enter new password",
"signUpButton": "Sign up",
"signIn": "Sign in",
"signUpWithGoogle": "Sign up with Google",
"signUpWithGithub": "Sign up with Github"
}
},
"validation": {
"invalidEmail": "Invalid email address",
"required": "Required",
"minLength": "Must be at least {length} characters",
"maxLength": "Must be no more than {length} characters",
"password": {
"requirements": "Password requirements",
"minLength": "Must be between 8 and 32 characters",
"uppercase": "Must contain at least one uppercase letter",
"lowercase": "Must contain at least one lowercase letter",
"number": "Must contain at least one number",
"special": "Must contain at least one special character",
"match": "Passwords must match"
}
}
}

View File

@@ -8,6 +8,42 @@
"message": "Este flujo de trabajo contiene nodos de API, que requieren que inicies sesión en tu cuenta para poder ejecutar.",
"title": "Se requiere iniciar sesión para usar los nodos de API"
},
"auth": {
"login": {
"andText": "y",
"confirmPasswordLabel": "Confirmar contraseña",
"confirmPasswordPlaceholder": "Ingresa la misma contraseña nuevamente",
"emailLabel": "Correo electrónico",
"emailPlaceholder": "Ingresa tu correo electrónico",
"failed": "Inicio de sesión fallido",
"forgotPassword": "¿Olvidaste tu contraseña?",
"loginButton": "Iniciar sesión",
"loginWithGithub": "Iniciar sesión con Github",
"loginWithGoogle": "Iniciar sesión con Google",
"newUser": "¿Eres nuevo aquí?",
"orContinueWith": "O continuar con",
"passwordLabel": "Contraseña",
"passwordPlaceholder": "Ingresa tu contraseña",
"privacyLink": "Política de privacidad",
"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"
},
"signup": {
"alreadyHaveAccount": "¿Ya tienes una cuenta?",
"emailLabel": "Correo electrónico",
"emailPlaceholder": "Ingresa tu correo electrónico",
"passwordLabel": "Contraseña",
"passwordPlaceholder": "Ingresa una nueva contraseña",
"signIn": "Iniciar sesión",
"signUpButton": "Registrarse",
"signUpWithGithub": "Registrarse con Github",
"signUpWithGoogle": "Registrarse con Google",
"title": "Crea una cuenta"
}
},
"clipboard": {
"errorMessage": "Error al copiar al portapapeles",
"errorNotSupported": "API del portapapeles no soportada en su navegador",
@@ -1043,6 +1079,21 @@
"next": "Siguiente",
"selectUser": "Selecciona un usuario"
},
"validation": {
"invalidEmail": "Dirección de correo electrónico inválida",
"maxLength": "No debe tener más de {length} caracteres",
"minLength": "Debe tener al menos {length} caracteres",
"password": {
"lowercase": "Debe contener al menos una letra minúscula",
"match": "Las contraseñas deben coincidir",
"minLength": "Debe tener entre 8 y 32 caracteres",
"number": "Debe contener al menos un número",
"requirements": "Requisitos de la contraseña",
"special": "Debe contener al menos un carácter especial",
"uppercase": "Debe contener al menos una letra mayúscula"
},
"required": "Requerido"
},
"welcome": {
"getStarted": "Empezar",
"title": "Bienvenido a ComfyUI"

View File

@@ -8,6 +8,42 @@
"message": "Ce flux de travail contient des nœuds API, qui nécessitent que vous soyez connecté à votre compte pour pouvoir fonctionner.",
"title": "Connexion requise pour utiliser les nœuds API"
},
"auth": {
"login": {
"andText": "et",
"confirmPasswordLabel": "Confirmer le mot de passe",
"confirmPasswordPlaceholder": "Entrez à nouveau le même mot de passe",
"emailLabel": "Email",
"emailPlaceholder": "Entrez votre email",
"failed": "Échec de la connexion",
"forgotPassword": "Mot de passe oublié?",
"loginButton": "Se connecter",
"loginWithGithub": "Se connecter avec Github",
"loginWithGoogle": "Se connecter avec Google",
"newUser": "Nouveau ici?",
"orContinueWith": "Ou continuer avec",
"passwordLabel": "Mot de passe",
"passwordPlaceholder": "Entrez votre mot de passe",
"privacyLink": "Politique de confidentialité",
"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"
},
"signup": {
"alreadyHaveAccount": "Vous avez déjà un compte?",
"emailLabel": "Email",
"emailPlaceholder": "Entrez votre email",
"passwordLabel": "Mot de passe",
"passwordPlaceholder": "Entrez un nouveau mot de passe",
"signIn": "Se connecter",
"signUpButton": "S'inscrire",
"signUpWithGithub": "S'inscrire avec Github",
"signUpWithGoogle": "S'inscrire avec Google",
"title": "Créer un compte"
}
},
"clipboard": {
"errorMessage": "Échec de la copie dans le presse-papiers",
"errorNotSupported": "L'API du presse-papiers n'est pas prise en charge par votre navigateur",
@@ -1043,6 +1079,21 @@
"next": "Suivant",
"selectUser": "Sélectionnez un utilisateur"
},
"validation": {
"invalidEmail": "Adresse e-mail invalide",
"maxLength": "Ne doit pas dépasser {length} caractères",
"minLength": "Doit contenir au moins {length} caractères",
"password": {
"lowercase": "Doit contenir au moins une lettre minuscule",
"match": "Les mots de passe doivent correspondre",
"minLength": "Doit contenir entre 8 et 32 caractères",
"number": "Doit contenir au moins un chiffre",
"requirements": "Exigences du mot de passe",
"special": "Doit contenir au moins un caractère spécial",
"uppercase": "Doit contenir au moins une lettre majuscule"
},
"required": "Requis"
},
"welcome": {
"getStarted": "Commencer",
"title": "Bienvenue sur ComfyUI"

View File

@@ -8,6 +8,42 @@
"message": "このワークフローにはAPIードが含まれており、実行するためにはアカウントにサインインする必要があります。",
"title": "APIードを使用するためにはサインインが必要です"
},
"auth": {
"login": {
"andText": "および",
"confirmPasswordLabel": "パスワードの確認",
"confirmPasswordPlaceholder": "もう一度同じパスワードを入力してください",
"emailLabel": "メール",
"emailPlaceholder": "メールアドレスを入力してください",
"failed": "ログイン失敗",
"forgotPassword": "パスワードを忘れましたか?",
"loginButton": "ログイン",
"loginWithGithub": "Githubでログイン",
"loginWithGoogle": "Googleでログイン",
"newUser": "新規ユーザーですか?",
"orContinueWith": "または以下で続ける",
"passwordLabel": "パスワード",
"passwordPlaceholder": "パスワードを入力してください",
"privacyLink": "プライバシーポリシー",
"signUp": "サインアップ",
"success": "ログイン成功",
"termsLink": "利用規約",
"termsText": "「次へ」または「サインアップ」をクリックすると、私たちの",
"title": "アカウントにログインする"
},
"signup": {
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
"emailLabel": "メール",
"emailPlaceholder": "メールアドレスを入力してください",
"passwordLabel": "パスワード",
"passwordPlaceholder": "新しいパスワードを入力してください",
"signIn": "サインイン",
"signUpButton": "サインアップ",
"signUpWithGithub": "Githubでサインアップ",
"signUpWithGoogle": "Googleでサインアップ",
"title": "アカウントを作成する"
}
},
"clipboard": {
"errorMessage": "クリップボードへのコピーに失敗しました",
"errorNotSupported": "お使いのブラウザではクリップボードAPIがサポートされていません",
@@ -1043,6 +1079,21 @@
"next": "次へ",
"selectUser": "ユーザーを選択"
},
"validation": {
"invalidEmail": "無効なメールアドレス",
"maxLength": "{length}文字以下でなければなりません",
"minLength": "{length}文字以上でなければなりません",
"password": {
"lowercase": "少なくとも1つの小文字を含む必要があります",
"match": "パスワードが一致する必要があります",
"minLength": "8文字から32文字の間でなければなりません",
"number": "少なくとも1つの数字を含む必要があります",
"requirements": "パスワードの要件",
"special": "少なくとも1つの特殊文字を含む必要があります",
"uppercase": "少なくとも1つの大文字を含む必要があります"
},
"required": "必須"
},
"welcome": {
"getStarted": "はじめる",
"title": "ComfyUIへようこそ"

View File

@@ -8,6 +8,42 @@
"message": "이 워크플로우에는 API 노드가 포함되어 있으며, 실행하려면 계정에 로그인해야 합니다.",
"title": "API 노드 사용에 필요한 로그인"
},
"auth": {
"login": {
"andText": "및",
"confirmPasswordLabel": "비밀번호 확인",
"confirmPasswordPlaceholder": "동일한 비밀번호를 다시 입력하세요",
"emailLabel": "이메일",
"emailPlaceholder": "이메일을 입력하세요",
"failed": "로그인 실패",
"forgotPassword": "비밀번호를 잊으셨나요?",
"loginButton": "로그인",
"loginWithGithub": "Github로 로그인",
"loginWithGoogle": "구글로 로그인",
"newUser": "처음이신가요?",
"orContinueWith": "또는 다음으로 계속",
"passwordLabel": "비밀번호",
"passwordPlaceholder": "비밀번호를 입력하세요",
"privacyLink": "개인정보 보호정책",
"signUp": "가입하기",
"success": "로그인 성공",
"termsLink": "이용 약관",
"termsText": "\"다음\" 또는 \"가입하기\"를 클릭하면 우리의",
"title": "계정에 로그인"
},
"signup": {
"alreadyHaveAccount": "이미 계정이 있으신가요?",
"emailLabel": "이메일",
"emailPlaceholder": "이메일을 입력하세요",
"passwordLabel": "비밀번호",
"passwordPlaceholder": "새 비밀번호를 입력하세요",
"signIn": "로그인",
"signUpButton": "가입하기",
"signUpWithGithub": "Github로 가입하기",
"signUpWithGoogle": "구글로 가입하기",
"title": "계정 생성"
}
},
"clipboard": {
"errorMessage": "클립보드에 복사하지 못했습니다",
"errorNotSupported": "브라우저가 클립보드 API를 지원하지 않습니다.",
@@ -1043,6 +1079,21 @@
"next": "다음",
"selectUser": "사용자 선택"
},
"validation": {
"invalidEmail": "유효하지 않은 이메일 주소",
"maxLength": "{length}자를 초과할 수 없습니다",
"minLength": "{length}자 이상이어야 합니다",
"password": {
"lowercase": "적어도 하나의 소문자를 포함해야 합니다",
"match": "비밀번호가 일치해야 합니다",
"minLength": "8자에서 32자 사이여야 합니다",
"number": "적어도 하나의 숫자를 포함해야 합니다",
"requirements": "비밀번호 요구사항",
"special": "적어도 하나의 특수 문자를 포함해야 합니다",
"uppercase": "적어도 하나의 대문자를 포함해야 합니다"
},
"required": "필수"
},
"welcome": {
"getStarted": "시작하기",
"title": "ComfyUI에 오신 것을 환영합니다"

View File

@@ -8,6 +8,42 @@
"message": "Этот рабочий процесс содержит API Nodes, которые требуют входа в вашу учетную запись для выполнения.",
"title": "Требуется вход для использования API Nodes"
},
"auth": {
"login": {
"andText": "и",
"confirmPasswordLabel": "Подтвердите пароль",
"confirmPasswordPlaceholder": "Введите тот же пароль еще раз",
"emailLabel": "Электронная почта",
"emailPlaceholder": "Введите вашу электронную почту",
"failed": "Вход не удался",
"forgotPassword": "Забыли пароль?",
"loginButton": "Войти",
"loginWithGithub": "Войти через Github",
"loginWithGoogle": "Войти через Google",
"newUser": "Вы здесь впервые?",
"orContinueWith": "Или продолжить с",
"passwordLabel": "Пароль",
"passwordPlaceholder": "Введите ваш пароль",
"privacyLink": "Политикой конфиденциальности",
"signUp": "Зарегистрироваться",
"success": "Вход выполнен успешно",
"termsLink": "Условиями использования",
"termsText": "Нажимая \"Далее\" или \"Зарегистрироваться\", вы соглашаетесь с нашими",
"title": "Войдите в свой аккаунт"
},
"signup": {
"alreadyHaveAccount": "Уже есть аккаунт?",
"emailLabel": "Электронная почта",
"emailPlaceholder": "Введите вашу электронную почту",
"passwordLabel": "Пароль",
"passwordPlaceholder": "Введите новый пароль",
"signIn": "Войти",
"signUpButton": "Зарегистрироваться",
"signUpWithGithub": "Зарегистрироваться через Github",
"signUpWithGoogle": "Зарегистрироваться через Google",
"title": "Создать аккаунт"
}
},
"clipboard": {
"errorMessage": "Не удалось скопировать в буфер обмена",
"errorNotSupported": "API буфера обмена не поддерживается в вашем браузере",
@@ -1043,6 +1079,21 @@
"next": "Далее",
"selectUser": "Выберите пользователя"
},
"validation": {
"invalidEmail": "Недействительный адрес электронной почты",
"maxLength": "Должно быть не более {length} символов",
"minLength": "Должно быть не менее {length} символов",
"password": {
"lowercase": "Должен содержать хотя бы одну строчную букву",
"match": "Пароли должны совпадать",
"minLength": "Должно быть от 8 до 32 символов",
"number": "Должен содержать хотя бы одну цифру",
"requirements": "Требования к паролю",
"special": "Должен содержать хотя бы один специальный символ",
"uppercase": "Должен содержать хотя бы одну заглавную букву"
},
"required": "Обязательно"
},
"welcome": {
"getStarted": "Начать",
"title": "Добро пожаловать в ComfyUI"

View File

@@ -8,6 +8,42 @@
"message": "此工作流包含API节点需要您登录账户才能运行。",
"title": "使用API节点需要登录"
},
"auth": {
"login": {
"andText": "和",
"confirmPasswordLabel": "确认密码",
"confirmPasswordPlaceholder": "再次输入相同的密码",
"emailLabel": "电子邮件",
"emailPlaceholder": "输入您的电子邮件",
"failed": "登录失败",
"forgotPassword": "忘记密码?",
"loginButton": "登录",
"loginWithGithub": "使用Github登录",
"loginWithGoogle": "使用Google登录",
"newUser": "新来的?",
"orContinueWith": "或者继续使用",
"passwordLabel": "密码",
"passwordPlaceholder": "输入您的密码",
"privacyLink": "隐私政策",
"signUp": "注册",
"success": "登录成功",
"termsLink": "使用条款",
"termsText": "点击“下一步”或“注册”即表示您同意我们的",
"title": "登录您的账户"
},
"signup": {
"alreadyHaveAccount": "已经有账户了?",
"emailLabel": "电子邮件",
"emailPlaceholder": "输入您的电子邮件",
"passwordLabel": "密码",
"passwordPlaceholder": "输入新密码",
"signIn": "登录",
"signUpButton": "注册",
"signUpWithGithub": "使用Github注册",
"signUpWithGoogle": "使用Google注册",
"title": "创建一个账户"
}
},
"clipboard": {
"errorMessage": "复制到剪贴板失败",
"errorNotSupported": "您的浏览器不支持剪贴板API",
@@ -1043,6 +1079,21 @@
"next": "下一步",
"selectUser": "选择用户"
},
"validation": {
"invalidEmail": "无效的电子邮件地址",
"maxLength": "不能超过{length}个字符",
"minLength": "必须至少有{length}个字符",
"password": {
"lowercase": "必须包含至少一个小写字母",
"match": "密码必须匹配",
"minLength": "必须在8到32个字符之间",
"number": "必须包含至少一个数字",
"requirements": "密码要求",
"special": "必须包含至少一个特殊字符",
"uppercase": "必须包含至少一个大写字母"
},
"required": "必填"
},
"welcome": {
"getStarted": "开始使用",
"title": "欢迎使用 ComfyUI"

View File

@@ -2,6 +2,7 @@ import '@comfyorg/litegraph/style.css'
import { definePreset } from '@primevue/themes'
import Aura from '@primevue/themes/aura'
import * as Sentry from '@sentry/vue'
import { initializeApp } from 'firebase/app'
import { createPinia } from 'pinia'
import 'primeicons/primeicons.css'
import PrimeVue from 'primevue/config'
@@ -9,8 +10,10 @@ import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
import { createApp } from 'vue'
import { VueFire, VueFireAuth } from 'vuefire'
import '@/assets/css/style.css'
import { FIREBASE_CONFIG } from '@/config/firebase'
import router from '@/router'
import App from './App.vue'
@@ -23,6 +26,8 @@ const ComfyUIPreset = definePreset(Aura, {
}
})
const firebaseApp = initializeApp(FIREBASE_CONFIG)
const app = createApp(App)
const pinia = createPinia()
Sentry.init({
@@ -58,4 +63,8 @@ app
.use(ToastService)
.use(pinia)
.use(i18n)
.use(VueFire, {
firebaseApp,
modules: [VueFireAuth()]
})
.mount('#vue-app')

View File

@@ -0,0 +1,36 @@
import { z } from 'zod'
import { t } from '@/i18n'
export const signInSchema = z.object({
email: z
.string()
.email(t('validation.invalidEmail'))
.min(1, t('validation.required')),
password: z.string().min(1, t('validation.required'))
})
export type SignInData = z.infer<typeof signInSchema>
export const signUpSchema = z
.object({
email: z
.string()
.email(t('validation.invalidEmail'))
.min(1, t('validation.required')),
password: z
.string()
.min(8, t('validation.minLength', { length: 8 }))
.max(32, t('validation.maxLength', { length: 32 }))
.regex(/[A-Z]/, t('validation.password.uppercase'))
.regex(/[a-z]/, t('validation.password.lowercase'))
.regex(/\d/, t('validation.password.number'))
.regex(/[^A-Za-z0-9]/, t('validation.password.special')),
confirmPassword: z.string().min(1, t('validation.required'))
})
.refine((data) => data.password === data.confirmPassword, {
message: t('validation.password.match'),
path: ['confirmPassword']
})
export type SignUpData = z.infer<typeof signUpSchema>

View File

@@ -1,3 +1,4 @@
import * as formatUtil from '@/utils/formatUtil'
import { applyTextReplacements as _applyTextReplacements } from '@/utils/searchAndReplace'
import { api } from './api'
@@ -121,3 +122,7 @@ export function setStorageValue(id: string, value: string) {
}
localStorage.setItem(id, value)
}
export function generateUUID() {
return formatUtil.generateUUID()
}

View File

@@ -7,6 +7,7 @@ import ManagerProgressDialogContent from '@/components/dialog/content/ManagerPro
import MissingModelsWarning from '@/components/dialog/content/MissingModelsWarning.vue'
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 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'
@@ -232,7 +233,7 @@ export const useDialogService = () => {
component: ApiNodesSignInContent,
props: {
apiNodes,
onLogin: () => resolve(true),
onLogin: () => showSignInDialog().then((result) => resolve(result)),
onCancel: () => resolve(false)
},
headerComponent: ComfyOrgHeader,
@@ -247,6 +248,26 @@ export const useDialogService = () => {
})
}
async function showSignInDialog(): Promise<boolean> {
return new Promise<boolean>((resolve) => {
dialogStore.showDialog({
key: 'global-signin',
component: SignInContent,
headerComponent: ComfyOrgHeader,
props: {
onSuccess: () => resolve(true)
},
dialogComponentProps: {
closable: false,
onClose: () => resolve(false)
}
})
}).then((result) => {
dialogStore.closeDialog({ key: 'global-signin' })
return result
})
}
async function prompt({
title,
message,
@@ -332,6 +353,7 @@ export const useDialogService = () => {
showManagerProgressDialog,
showErrorDialog,
showApiNodesSignInDialog,
showSignInDialog,
prompt,
confirm
}

View File

@@ -0,0 +1,99 @@
import {
type Auth,
type User,
type UserCredential,
createUserWithEmailAndPassword,
onAuthStateChanged,
signInWithEmailAndPassword,
signOut
} from 'firebase/auth'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useFirebaseAuth } from 'vuefire'
export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
// State
const loading = ref(false)
const error = ref<string | null>(null)
const currentUser = ref<User | null>(null)
const isInitialized = ref(false)
// Getters
const isAuthenticated = computed(() => !!currentUser.value)
const userEmail = computed(() => currentUser.value?.email)
const userId = computed(() => currentUser.value?.uid)
// Get auth from VueFire and listen for auth state changes
const auth = useFirebaseAuth()
if (auth) {
onAuthStateChanged(auth, (user) => {
currentUser.value = user
isInitialized.value = true
})
} else {
error.value = 'Firebase Auth not available from VueFire'
}
const executeAuthAction = async <T>(
action: (auth: Auth) => Promise<T>
): Promise<T> => {
if (!auth) throw new Error('Firebase Auth not initialized')
loading.value = true
error.value = null
try {
return await action(auth)
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Unknown error'
throw e
} finally {
loading.value = false
}
}
const login = async (
email: string,
password: string
): Promise<UserCredential> =>
executeAuthAction((authInstance) =>
signInWithEmailAndPassword(authInstance, email, password)
)
const register = async (
email: string,
password: string
): Promise<UserCredential> =>
executeAuthAction((authInstance) =>
createUserWithEmailAndPassword(authInstance, email, password)
)
const logout = async (): Promise<void> =>
executeAuthAction((authInstance) => signOut(authInstance))
const getIdToken = async (): Promise<string | null> => {
if (currentUser.value) {
return currentUser.value.getIdToken()
}
return null
}
return {
// State
loading,
error,
currentUser,
isInitialized,
// Getters
isAuthenticated,
userEmail,
userId,
// Actions
login,
register,
logout,
getIdToken
}
})

View File

@@ -21,6 +21,43 @@ export interface paths {
patch?: never
trace?: never
}
'/customer': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
/**
* Create a new customer
* @description Creates a new customer using the provided token. No request body is needed as user information is extracted from the token.
*/
post: operations['createCustomer']
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/customer/credit': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
/** Initiates a Credit Purchase. */
post: operations['InitiateCreditPurchase']
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/upload-artifact': {
parameters: {
query?: never
@@ -900,6 +937,98 @@ export interface paths {
patch?: never
trace?: never
}
'/proxy/ideogram/generate': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
/**
* Proxy request to Ideogram for image generation
* @description Forwards image generation requests to Ideogram's API and returns the results.
*/
post: operations['ideogramGenerate']
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/webhook/metronome/zero-balance': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
/** receive alert on remaining balance is 0 */
post: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody: {
content: {
'application/json': {
properties?: {
/** @description the metronome customer id */
customer_id?: string
/** @description the customer remaining balance */
remaining_balance?: number
}
}
}
}
responses: {
/** @description Webhook processed succesfully */
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['IdeogramGenerateResponse']
}
}
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Internal Server Error (proxy or upstream issue) */
500: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
}
}
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
}
export type webhooks = Record<string, never>
export interface components {
@@ -1225,6 +1354,81 @@ export interface components {
/** @description The pip freeze output */
pip_freeze?: string
}
Customer: {
/** @description The firebase UID of the user */
id: string
/** @description The email address for this user */
email?: string
/** @description The name for this user */
name?: string
/**
* Format: date-time
* @description The date and time the user was created
*/
createdAt?: string
/**
* Format: date-time
* @description The date and time the user was last updated
*/
updatedAt?: string
}
/** @description Parameters for the Ideogram generation proxy request. Based on Ideogram's API. */
IdeogramGenerateRequest: {
/** @description The image generation request parameters. */
image_request: {
/** @description Required. The prompt to use to generate the image. */
prompt: string
/** @description Optional. The aspect ratio (e.g., 'ASPECT_16_9', 'ASPECT_1_1'). Cannot be used with resolution. Defaults to 'ASPECT_1_1' if unspecified. */
aspect_ratio?: string
/** @description Optional. The model used (e.g., 'V_2', 'V_2A_TURBO'). Defaults to 'V_2' if unspecified. */
model?: string
/** @description Optional. MagicPrompt usage ('AUTO', 'ON', 'OFF'). */
magic_prompt_option?: string
/**
* Format: int64
* @description Optional. A number between 0 and 2147483647.
*/
seed?: number
/** @description Optional. Style type ('AUTO', 'GENERAL', 'REALISTIC', 'DESIGN', 'RENDER_3D', 'ANIME'). Only for models V_2 and above. */
style_type?: string
/** @description Optional. Description of what to exclude. Only for V_1, V_1_TURBO, V_2, V_2_TURBO. */
negative_prompt?: string
/**
* @description Optional. Number of images to generate (1-8). Defaults to 1.
* @default 1
*/
num_images: number
/** @description Optional. Resolution (e.g., 'RESOLUTION_1024_1024'). Only for model V_2. Cannot be used with aspect_ratio. */
resolution?: string
/** @description Optional. Color palette object. Only for V_2, V_2_TURBO. */
color_palette?: {
[key: string]: unknown
}
}
}
/** @description Response from the Ideogram image generation API. */
IdeogramGenerateResponse: {
/**
* Format: date-time
* @description Timestamp when the generation was created.
*/
created?: string
/** @description Array of generated image information. */
data?: {
/** @description The prompt used to generate this image. */
prompt?: string
/** @description The resolution of the generated image (e.g., '1024x1024'). */
resolution?: string
/** @description Indicates whether the image is considered safe. */
is_image_safe?: boolean
/** @description The seed value used for this generation. */
seed?: number
/** @description URL to the generated image. */
url?: string
/** @description The style type used for generation (e.g., 'REALISTIC', 'ANIME'). */
style_type?: string
}[]
}
}
responses: never
parameters: never
@@ -1268,6 +1472,111 @@ export interface operations {
}
}
}
createCustomer: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody?: never
responses: {
/** @description Customer created successfully */
201: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['Customer']
}
}
/** @description Bad request, invalid token or user already exists */
400: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Unauthorized or invalid token */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
}
}
InitiateCreditPurchase: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody: {
content: {
'application/json': {
/**
* Format: int64
* @description the amount of the checkout transaction in micro value
*/
amount_micros?: number
/** @description the currency used in the checkout transaction */
currency?: string
}
}
}
responses: {
/** @description Customer Checkout created successfully */
201: {
headers: {
[name: string]: unknown
}
content: {
'application/json': {
/** @description the url to redirect the customer */
checkout_url?: string
}
}
}
/** @description Bad request, invalid token or user already exists */
400: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Unauthorized or invalid token */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
}
}
getWorkflowResult: {
parameters: {
query?: never
@@ -3417,4 +3726,80 @@ export interface operations {
}
}
}
ideogramGenerate: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody: {
content: {
'application/json': components['schemas']['IdeogramGenerateRequest']
}
}
responses: {
/** @description Successful response from Ideogram proxy */
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['IdeogramGenerateResponse']
}
}
/** @description Bad Request (invalid input to proxy) */
400: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Rate limit exceeded (either from proxy or Ideogram) */
429: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Internal Server Error (proxy or upstream issue) */
500: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Bad Gateway (error communicating with Ideogram) */
502: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Gateway Timeout (Ideogram took too long to respond) */
504: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
}
}
}

View File

@@ -0,0 +1,275 @@
import * as firebaseAuth from 'firebase/auth'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as vuefire from 'vuefire'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
vi.mock('vuefire', () => ({
useFirebaseAuth: vi.fn()
}))
vi.mock('firebase/auth', () => ({
signInWithEmailAndPassword: vi.fn(),
createUserWithEmailAndPassword: vi.fn(),
signOut: vi.fn(),
onAuthStateChanged: vi.fn()
}))
describe('useFirebaseAuthStore', () => {
let store: ReturnType<typeof useFirebaseAuthStore>
let authStateCallback: (user: any) => void
const mockAuth = {
/* mock Auth object */
}
const mockUser = {
uid: 'test-user-id',
email: 'test@example.com',
getIdToken: vi.fn().mockResolvedValue('mock-id-token')
}
beforeEach(() => {
vi.resetAllMocks()
// Mock useFirebaseAuth to return our mock auth object
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(mockAuth as any)
// Mock onAuthStateChanged to capture the callback and simulate initial auth state
vi.mocked(firebaseAuth.onAuthStateChanged).mockImplementation(
(_, callback) => {
authStateCallback = callback as (user: any) => void
// Call the callback with our mock user
;(callback as (user: any) => void)(mockUser)
// Return an unsubscribe function
return vi.fn()
}
)
// Initialize Pinia
setActivePinia(createPinia())
store = useFirebaseAuthStore()
})
it('should initialize with the current user', () => {
expect(store.currentUser).toEqual(mockUser)
expect(store.isAuthenticated).toBe(true)
expect(store.userEmail).toBe('test@example.com')
expect(store.userId).toBe('test-user-id')
expect(store.loading).toBe(false)
expect(store.error).toBe(null)
})
it('should properly clean up error state between operations', async () => {
// First, cause an error
const mockError = new Error('Invalid password')
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockRejectedValueOnce(
mockError
)
try {
await store.login('test@example.com', 'wrong-password')
} catch (e) {
// Error expected
}
expect(store.error).toBe('Invalid password')
// Now, succeed on next attempt
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValueOnce({
user: mockUser
} as any)
await store.login('test@example.com', 'correct-password')
// Error should be cleared
expect(store.error).toBe(null)
})
it('should handle auth initialization failure', async () => {
// Mock auth as null to simulate initialization failure
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(null)
// Create a new store instance
setActivePinia(createPinia())
const uninitializedStore = useFirebaseAuthStore()
// Check that isInitialized is false
expect(uninitializedStore.isInitialized).toBe(false)
// Verify store actions throw appropriate errors
await expect(
uninitializedStore.login('test@example.com', 'password')
).rejects.toThrow('Firebase Auth not initialized')
})
describe('login', () => {
it('should login with valid credentials', async () => {
const mockUserCredential = { user: mockUser }
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
mockUserCredential as any
)
const result = await store.login('test@example.com', 'password')
expect(firebaseAuth.signInWithEmailAndPassword).toHaveBeenCalledWith(
mockAuth,
'test@example.com',
'password'
)
expect(result).toEqual(mockUserCredential)
expect(store.loading).toBe(false)
expect(store.error).toBe(null)
})
it('should handle login errors', async () => {
const mockError = new Error('Invalid password')
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockRejectedValue(
mockError
)
await expect(
store.login('test@example.com', 'wrong-password')
).rejects.toThrow('Invalid password')
expect(firebaseAuth.signInWithEmailAndPassword).toHaveBeenCalledWith(
mockAuth,
'test@example.com',
'wrong-password'
)
expect(store.loading).toBe(false)
expect(store.error).toBe('Invalid password')
})
})
describe('register', () => {
it('should register a new user', async () => {
const mockUserCredential = { user: mockUser }
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockResolvedValue(
mockUserCredential as any
)
const result = await store.register('new@example.com', 'password')
expect(firebaseAuth.createUserWithEmailAndPassword).toHaveBeenCalledWith(
mockAuth,
'new@example.com',
'password'
)
expect(result).toEqual(mockUserCredential)
expect(store.loading).toBe(false)
expect(store.error).toBe(null)
})
it('should handle registration errors', async () => {
const mockError = new Error('Email already in use')
vi.mocked(firebaseAuth.createUserWithEmailAndPassword).mockRejectedValue(
mockError
)
await expect(
store.register('existing@example.com', 'password')
).rejects.toThrow('Email already in use')
expect(firebaseAuth.createUserWithEmailAndPassword).toHaveBeenCalledWith(
mockAuth,
'existing@example.com',
'password'
)
expect(store.loading).toBe(false)
expect(store.error).toBe('Email already in use')
})
it('should handle concurrent login attempts correctly', async () => {
// Set up multiple login promises
const mockUserCredential = { user: mockUser }
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
mockUserCredential as any
)
const loginPromise1 = store.login('user1@example.com', 'password1')
const loginPromise2 = store.login('user2@example.com', 'password2')
// Resolve both promises
await Promise.all([loginPromise1, loginPromise2])
// Verify the loading state is reset
expect(store.loading).toBe(false)
})
})
describe('logout', () => {
it('should sign out the user', async () => {
vi.mocked(firebaseAuth.signOut).mockResolvedValue(undefined)
await store.logout()
expect(firebaseAuth.signOut).toHaveBeenCalledWith(mockAuth)
})
it('should handle logout errors', async () => {
const mockError = new Error('Network error')
vi.mocked(firebaseAuth.signOut).mockRejectedValue(mockError)
await expect(store.logout()).rejects.toThrow('Network error')
expect(firebaseAuth.signOut).toHaveBeenCalledWith(mockAuth)
expect(store.error).toBe('Network error')
})
})
describe('getIdToken', () => {
it('should return the user ID token', async () => {
// FIX 2: Reset the mock and set a specific return value
mockUser.getIdToken.mockReset()
mockUser.getIdToken.mockResolvedValue('mock-id-token')
const token = await store.getIdToken()
expect(mockUser.getIdToken).toHaveBeenCalled()
expect(token).toBe('mock-id-token')
})
it('should return null when no user is logged in', async () => {
// Simulate logged out state
authStateCallback(null)
const token = await store.getIdToken()
expect(token).toBeNull()
})
it('should return null for token after login and logout sequence', async () => {
// Setup mock for login
const mockUserCredential = { user: mockUser }
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue(
mockUserCredential as any
)
// Login
await store.login('test@example.com', 'password')
// Simulate successful auth state update after login
authStateCallback(mockUser)
// Verify we're logged in and can get a token
mockUser.getIdToken.mockReset()
mockUser.getIdToken.mockResolvedValue('mock-id-token')
expect(await store.getIdToken()).toBe('mock-id-token')
// Setup mock for logout
vi.mocked(firebaseAuth.signOut).mockResolvedValue(undefined)
// Logout
await store.logout()
// Simulate successful auth state update after logout
authStateCallback(null)
// Verify token is null after logout
const tokenAfterLogout = await store.getIdToken()
expect(tokenAfterLogout).toBeNull()
})
})
})

View File

@@ -8,7 +8,7 @@
"incremental": true,
"sourceMap": true,
"esModuleInterop": true,
"moduleResolution": "Node",
"moduleResolution": "bundler",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"resolveJsonModule": true,