From 8558f875477e3092966394226d7575688dfb02ce Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Wed, 23 Apr 2025 06:48:45 +0800 Subject: [PATCH] [API Node] User management (#3567) Co-authored-by: github-actions Co-authored-by: Chenlei Hu --- .../dialog/content/SettingDialogContent.vue | 10 +- .../dialog/content/SignInContent.vue | 2 +- .../content/SignInRequiredDialogContent.vue | 47 +++++ .../content/TopUpCreditsDialogContent.vue | 158 ++++++++++++++ .../dialog/content/setting/CreditsPanel.vue | 184 +++++++++------- .../dialog/content/setting/UserPanel.vue | 137 ++++++++++++ .../dialog/content/signin/SignInForm.vue | 8 + src/components/topbar/TopMenubar.vue | 27 +++ src/composables/setting/useSettingUI.ts | 44 ++-- src/config/firebase.ts | 22 +- src/locales/en/main.json | 46 +++- src/locales/es/main.json | 42 +++- src/locales/fr/main.json | 42 +++- src/locales/ja/main.json | 42 +++- src/locales/ko/main.json | 42 +++- src/locales/ru/main.json | 42 +++- src/locales/zh/main.json | 42 +++- src/scripts/app.ts | 12 +- src/services/dialogService.ts | 35 +++- src/stores/firebaseAuthStore.ts | 196 ++++++++++++++++-- src/utils/formatUtil.ts | 43 ++++ .../tests/store/firebaseAuthStore.test.ts | 106 ++++++++-- 22 files changed, 1174 insertions(+), 155 deletions(-) create mode 100644 src/components/dialog/content/SignInRequiredDialogContent.vue create mode 100644 src/components/dialog/content/TopUpCreditsDialogContent.vue create mode 100644 src/components/dialog/content/setting/UserPanel.vue diff --git a/src/components/dialog/content/SettingDialogContent.vue b/src/components/dialog/content/SettingDialogContent.vue index b0b896668..4fa3b3908 100644 --- a/src/components/dialog/content/SettingDialogContent.vue +++ b/src/components/dialog/content/SettingDialogContent.vue @@ -48,6 +48,7 @@ + @@ -96,9 +97,16 @@ import CurrentUserMessage from './setting/CurrentUserMessage.vue' import FirstTimeUIMessage from './setting/FirstTimeUIMessage.vue' import PanelTemplate from './setting/PanelTemplate.vue' import SettingsPanel from './setting/SettingsPanel.vue' +import UserPanel from './setting/UserPanel.vue' const { defaultPanel } = defineProps<{ - defaultPanel?: 'about' | 'keybinding' | 'extension' | 'server-config' + defaultPanel?: + | 'about' + | 'keybinding' + | 'extension' + | 'server-config' + | 'user' + | 'credits' }>() const KeybindingPanel = defineAsyncComponent( diff --git a/src/components/dialog/content/SignInContent.vue b/src/components/dialog/content/SignInContent.vue index 3c1542430..35a72ac03 100644 --- a/src/components/dialog/content/SignInContent.vue +++ b/src/components/dialog/content/SignInContent.vue @@ -70,7 +70,7 @@ {{ t('auth.login.andText') }} diff --git a/src/components/dialog/content/SignInRequiredDialogContent.vue b/src/components/dialog/content/SignInRequiredDialogContent.vue new file mode 100644 index 000000000..f951bc5ce --- /dev/null +++ b/src/components/dialog/content/SignInRequiredDialogContent.vue @@ -0,0 +1,47 @@ + + + diff --git a/src/components/dialog/content/TopUpCreditsDialogContent.vue b/src/components/dialog/content/TopUpCreditsDialogContent.vue new file mode 100644 index 000000000..8e262bfaf --- /dev/null +++ b/src/components/dialog/content/TopUpCreditsDialogContent.vue @@ -0,0 +1,158 @@ + + + diff --git a/src/components/dialog/content/setting/CreditsPanel.vue b/src/components/dialog/content/setting/CreditsPanel.vue index 2a58985c8..16d2ca8b6 100644 --- a/src/components/dialog/content/setting/CreditsPanel.vue +++ b/src/components/dialog/content/setting/CreditsPanel.vue @@ -19,52 +19,71 @@ rounded class="text-amber-400 p-1" /> -
{{ creditBalance }}
+
{{ formattedBalance }}
+ + +
authStore.isAuthenticated) const workflowTabsPosition = computed(() => settingStore.get('Comfy.Workflow.WorkflowTabsPosition') ) @@ -66,6 +89,10 @@ const showTopMenu = computed( () => betaMenuEnabled.value && !workspaceState.focusMode ) +const openUserSettings = () => { + dialogService.showSettingsDialog('user') +} + const menuRight = ref(null) // Menu-right holds legacy topbar elements attached by custom scripts onMounted(() => { diff --git a/src/composables/setting/useSettingUI.ts b/src/composables/setting/useSettingUI.ts index d8ccb8a28..55934fbae 100644 --- a/src/composables/setting/useSettingUI.ts +++ b/src/composables/setting/useSettingUI.ts @@ -9,7 +9,13 @@ import { normalizeI18nKey } from '@/utils/formatUtil' import { buildTree } from '@/utils/treeUtil' export function useSettingUI( - defaultPanel?: 'about' | 'keybinding' | 'extension' | 'server-config' + defaultPanel?: + | 'about' + | 'keybinding' + | 'extension' + | 'server-config' + | 'user' + | 'credits' ) { const { t } = useI18n() const firebaseAuthStore = useFirebaseAuthStore() @@ -55,6 +61,12 @@ export function useSettingUI( children: [] } + const userPanelNode: SettingTreeNode = { + key: 'user', + label: 'User', + children: [] + } + const keybindingPanelNode: SettingTreeNode = { key: 'keybinding', label: 'Keybinding', @@ -84,10 +96,13 @@ export function useSettingUI( * The default category to show when the dialog is opened. */ const defaultCategory = computed(() => { - return defaultPanel - ? settingCategories.value.find((x) => x.key === defaultPanel) ?? - settingCategories.value[0] - : settingCategories.value[0] + if (!defaultPanel) return settingCategories.value[0] + // Search through all groups in groupedMenuTreeNodes + for (const group of groupedMenuTreeNodes.value) { + const found = group.children?.find((node) => node.key === defaultPanel) + if (found) return found + } + return settingCategories.value[0] }) const translateCategory = (node: SettingTreeNode) => ({ @@ -99,16 +114,15 @@ export function useSettingUI( }) const groupedMenuTreeNodes = computed(() => [ - // Account settings - only show when user is authenticated - ...(firebaseAuthStore.isAuthenticated - ? [ - { - key: 'account', - label: 'Account', - children: [creditsPanelNode].map(translateCategory) - } - ] - : []), + // Account settings - only show credits when user is authenticated + { + key: 'account', + label: 'Account', + children: [ + userPanelNode, + ...(firebaseAuthStore.isAuthenticated ? [creditsPanelNode] : []) + ].map(translateCategory) + }, // Normal settings stored in the settingStore { key: 'settings', diff --git a/src/config/firebase.ts b/src/config/firebase.ts index 57735f2c5..a8a62c1cd 100644 --- a/src/config/firebase.ts +++ b/src/config/firebase.ts @@ -1,15 +1,15 @@ import { FirebaseOptions } from 'firebase/app' -const DEV_CONFIG: FirebaseOptions = { - apiKey: 'AIzaSyDa_YMeyzV0SkVe92vBZ1tVikWBmOU5KVE', - authDomain: 'dreamboothy-dev.firebaseapp.com', - databaseURL: 'https://dreamboothy-dev-default-rtdb.firebaseio.com', - projectId: 'dreamboothy-dev', - storageBucket: 'dreamboothy-dev.appspot.com', - messagingSenderId: '313257147182', - appId: '1:313257147182:web:be38f6ebf74345fc7618bf', - measurementId: 'G-YEVSMYXSPY' -} +// const DEV_CONFIG: FirebaseOptions = { +// apiKey: 'AIzaSyDa_YMeyzV0SkVe92vBZ1tVikWBmOU5KVE', +// authDomain: 'dreamboothy-dev.firebaseapp.com', +// databaseURL: 'https://dreamboothy-dev-default-rtdb.firebaseio.com', +// projectId: 'dreamboothy-dev', +// storageBucket: 'dreamboothy-dev.appspot.com', +// messagingSenderId: '313257147182', +// appId: '1:313257147182:web:be38f6ebf74345fc7618bf', +// measurementId: 'G-YEVSMYXSPY' +// } const PROD_CONFIG: FirebaseOptions = { apiKey: 'AIzaSyC2-fomLqgCjb7ELwta1I9cEarPK8ziTGs', @@ -26,4 +26,4 @@ const PROD_CONFIG: FirebaseOptions = { // Otherwise, build with `npm run build` the and set `--front-end-root` to `ComfyUI_frontend/dist` export const FIREBASE_CONFIG: FirebaseOptions = __USE_PROD_FIREBASE_CONFIG__ ? PROD_CONFIG - : DEV_CONFIG + : PROD_CONFIG // Just force prod to save time for now. change back later diff --git a/src/locales/en/main.json b/src/locales/en/main.json index f66185eca..d856d2df4 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1063,6 +1063,7 @@ "auth": { "login": { "title": "Log in to your account", + "signInOrSignUp": "Sign In / Sign Up", "newUser": "New here?", "signUp": "Sign up", "emailLabel": "Email", @@ -1081,7 +1082,8 @@ "andText": "and", "privacyLink": "Privacy Policy", "success": "Login successful", - "failed": "Login failed" + "failed": "Login failed", + "genericErrorMessage": "Sorry, we've encountered an error. Please contact {supportEmail}." }, "signup": { "title": "Create an account", @@ -1094,6 +1096,25 @@ "signIn": "Sign in", "signUpWithGoogle": "Sign up with Google", "signUpWithGithub": "Sign up with Github" + }, + "signOut": { + "signOut": "Log Out", + "success": "Signed out successfully", + "successDetail": "You have been signed out of your account." + }, + "required": { + "signIn": { + "title": "Sign-In Required to Execute Workflow", + "message": "This workflow includes nodes that require an active account. Please log in or create one to continue.", + "hint": "To login go to: Settings > User > Login", + "action": "Open Settings to Login" + }, + "credits": { + "title": "Credits Required to Execute Workflow", + "message": "This workflow includes nodes that require credits. Please add credits to your account to continue.", + "hint": "To add credits go to: Settings > User > Credits", + "action": "Open Settings to Add Credits" + } } }, "validation": { @@ -1116,8 +1137,27 @@ "yourCreditBalance": "Your credit balance", "purchaseCredits": "Purchase Credits", "creditsHistory": "Credits History", - "paymentDetails": "Payment Details", "faqs": "FAQs", - "messageSupport": "Message Support" + "messageSupport": "Message Support", + "lastUpdated": "Last updated", + "topUp": { + "title": "Add to Credit Balance", + "insufficientTitle": "Insufficient Credits", + "insufficientMessage": "You don't have enough credits to run this workflow.", + "addCredits": "Add credits to your balance", + "maxAmount": "(Max. $1,000 USD)", + "buyNow": "Buy now" + } + }, + "userSettings": { + "title": "User Settings", + "name": "Name", + "email": "Email", + "notSet": "Not set", + "provider": "Sign in method", + "providers": { + "google": "Google", + "github": "GitHub" + } } } \ No newline at end of file diff --git a/src/locales/es/main.json b/src/locales/es/main.json index edeefd5e6..2bad0999f 100644 --- a/src/locales/es/main.json +++ b/src/locales/es/main.json @@ -17,6 +17,7 @@ "emailPlaceholder": "Ingresa tu correo electrónico", "failed": "Inicio de sesión fallido", "forgotPassword": "¿Olvidaste tu contraseña?", + "genericErrorMessage": "Lo sentimos, hemos encontrado un error. Por favor, contacta con {supportEmail}.", "loginButton": "Iniciar sesión", "loginWithGithub": "Iniciar sesión con Github", "loginWithGoogle": "Iniciar sesión con Google", @@ -25,12 +26,32 @@ "passwordLabel": "Contraseña", "passwordPlaceholder": "Ingresa tu contraseña", "privacyLink": "Política de privacidad", + "signInOrSignUp": "Iniciar sesión / Registrarse", "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" }, + "required": { + "credits": { + "action": "Abrir configuración para añadir créditos", + "hint": "Para añadir créditos ve a: Configuración > Usuario > Créditos", + "message": "Este flujo de trabajo incluye nodos que requieren créditos. Por favor, añade créditos a tu cuenta para continuar.", + "title": "Créditos requeridos para ejecutar el flujo de trabajo" + }, + "signIn": { + "action": "Abrir configuración para iniciar sesión", + "hint": "Para iniciar sesión ve a: Configuración > Usuario > Iniciar sesión", + "message": "Este flujo de trabajo incluye nodos que requieren una cuenta activa. Por favor, inicia sesión o crea una para continuar.", + "title": "Inicio de sesión requerido para ejecutar el flujo de trabajo" + } + }, + "signOut": { + "signOut": "Cerrar sesión", + "success": "Sesión cerrada correctamente", + "successDetail": "Has cerrado sesión en tu cuenta." + }, "signup": { "alreadyHaveAccount": "¿Ya tienes una cuenta?", "emailLabel": "Correo electrónico", @@ -96,9 +117,17 @@ "credits": "Créditos", "creditsHistory": "Historial de créditos", "faqs": "Preguntas frecuentes", + "lastUpdated": "Última actualización", "messageSupport": "Contactar soporte", - "paymentDetails": "Detalles de pago", "purchaseCredits": "Comprar créditos", + "topUp": { + "addCredits": "Agregar créditos a tu saldo", + "buyNow": "Comprar ahora", + "insufficientMessage": "No tienes suficientes créditos para ejecutar este flujo de trabajo.", + "insufficientTitle": "Créditos insuficientes", + "maxAmount": "(Máx. $1,000 USD)", + "title": "Agregar al saldo de créditos" + }, "yourCreditBalance": "Tu saldo de créditos" }, "dataTypes": { @@ -1096,6 +1125,17 @@ "next": "Siguiente", "selectUser": "Selecciona un usuario" }, + "userSettings": { + "email": "Correo electrónico", + "name": "Nombre", + "notSet": "No establecido", + "provider": "Método de inicio de sesión", + "providers": { + "github": "GitHub", + "google": "Google" + }, + "title": "Configuración de usuario" + }, "validation": { "invalidEmail": "Dirección de correo electrónico inválida", "maxLength": "No debe tener más de {length} caracteres", diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json index 27c8a7d08..f1ebb9a99 100644 --- a/src/locales/fr/main.json +++ b/src/locales/fr/main.json @@ -17,6 +17,7 @@ "emailPlaceholder": "Entrez votre email", "failed": "Échec de la connexion", "forgotPassword": "Mot de passe oublié?", + "genericErrorMessage": "Désolé, une erreur s'est produite. Veuillez contacter {supportEmail}.", "loginButton": "Se connecter", "loginWithGithub": "Se connecter avec Github", "loginWithGoogle": "Se connecter avec Google", @@ -25,12 +26,32 @@ "passwordLabel": "Mot de passe", "passwordPlaceholder": "Entrez votre mot de passe", "privacyLink": "Politique de confidentialité", + "signInOrSignUp": "Se connecter / S’inscrire", "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" }, + "required": { + "credits": { + "action": "Ouvrir les paramètres pour ajouter des crédits", + "hint": "Pour ajouter des crédits, allez dans : Paramètres > Utilisateur > Crédits", + "message": "Ce workflow inclut des nœuds nécessitant des crédits. Veuillez ajouter des crédits à votre compte pour continuer.", + "title": "Crédits requis pour exécuter le workflow" + }, + "signIn": { + "action": "Ouvrir les paramètres pour se connecter", + "hint": "Pour vous connecter, allez dans : Paramètres > Utilisateur > Connexion", + "message": "Ce workflow inclut des nœuds nécessitant un compte actif. Veuillez vous connecter ou en créer un pour continuer.", + "title": "Connexion requise pour exécuter le workflow" + } + }, + "signOut": { + "signOut": "Se déconnecter", + "success": "Déconnexion réussie", + "successDetail": "Vous avez été déconnecté de votre compte." + }, "signup": { "alreadyHaveAccount": "Vous avez déjà un compte?", "emailLabel": "Email", @@ -96,9 +117,17 @@ "credits": "Crédits", "creditsHistory": "Historique des crédits", "faqs": "FAQ", + "lastUpdated": "Dernière mise à jour", "messageSupport": "Contacter le support", - "paymentDetails": "Détails de paiement", "purchaseCredits": "Acheter des crédits", + "topUp": { + "addCredits": "Ajouter des crédits à votre solde", + "buyNow": "Acheter maintenant", + "insufficientMessage": "Vous n'avez pas assez de crédits pour exécuter ce workflow.", + "insufficientTitle": "Crédits insuffisants", + "maxAmount": "(Max. 1 000 $ US)", + "title": "Ajouter au solde de crédits" + }, "yourCreditBalance": "Votre solde de crédits" }, "dataTypes": { @@ -1096,6 +1125,17 @@ "next": "Suivant", "selectUser": "Sélectionnez un utilisateur" }, + "userSettings": { + "email": "E-mail", + "name": "Nom", + "notSet": "Non défini", + "provider": "Méthode de connexion", + "providers": { + "github": "GitHub", + "google": "Google" + }, + "title": "Paramètres utilisateur" + }, "validation": { "invalidEmail": "Adresse e-mail invalide", "maxLength": "Ne doit pas dépasser {length} caractères", diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json index c951bef6e..06f53b534 100644 --- a/src/locales/ja/main.json +++ b/src/locales/ja/main.json @@ -17,6 +17,7 @@ "emailPlaceholder": "メールアドレスを入力してください", "failed": "ログイン失敗", "forgotPassword": "パスワードを忘れましたか?", + "genericErrorMessage": "申し訳ありませんが、エラーが発生しました。{supportEmail} までご連絡ください。", "loginButton": "ログイン", "loginWithGithub": "Githubでログイン", "loginWithGoogle": "Googleでログイン", @@ -25,12 +26,32 @@ "passwordLabel": "パスワード", "passwordPlaceholder": "パスワードを入力してください", "privacyLink": "プライバシーポリシー", + "signInOrSignUp": "サインイン / サインアップ", "signUp": "サインアップ", "success": "ログイン成功", "termsLink": "利用規約", "termsText": "「次へ」または「サインアップ」をクリックすると、私たちの", "title": "アカウントにログインする" }, + "required": { + "credits": { + "action": "設定を開いてクレジットを追加", + "hint": "クレジットを追加するには: 設定 > ユーザー > クレジット", + "message": "このワークフローにはクレジットが必要なノードが含まれています。続行するにはアカウントにクレジットを追加してください。", + "title": "ワークフロー実行にはクレジットが必要です" + }, + "signIn": { + "action": "設定を開いてログイン", + "hint": "ログインするには: 設定 > ユーザー > ログイン", + "message": "このワークフローにはアクティブなアカウントが必要なノードが含まれています。続行するにはログインまたはアカウントを作成してください。", + "title": "ワークフロー実行にはサインインが必要です" + } + }, + "signOut": { + "signOut": "ログアウト", + "success": "正常にサインアウトしました", + "successDetail": "アカウントからサインアウトしました。" + }, "signup": { "alreadyHaveAccount": "すでにアカウントをお持ちですか?", "emailLabel": "メール", @@ -96,9 +117,17 @@ "credits": "クレジット", "creditsHistory": "クレジット履歴", "faqs": "よくある質問", + "lastUpdated": "最終更新", "messageSupport": "サポートにメッセージ", - "paymentDetails": "支払い詳細", "purchaseCredits": "クレジットを購入", + "topUp": { + "addCredits": "残高にクレジットを追加", + "buyNow": "今すぐ購入", + "insufficientMessage": "このワークフローを実行するのに十分なクレジットがありません。", + "insufficientTitle": "クレジット不足", + "maxAmount": "(最大 $1,000 USD)", + "title": "クレジット残高を追加" + }, "yourCreditBalance": "あなたのクレジット残高" }, "dataTypes": { @@ -1096,6 +1125,17 @@ "next": "次へ", "selectUser": "ユーザーを選択" }, + "userSettings": { + "email": "メールアドレス", + "name": "名前", + "notSet": "未設定", + "provider": "サインイン方法", + "providers": { + "github": "GitHub", + "google": "Google" + }, + "title": "ユーザー設定" + }, "validation": { "invalidEmail": "無効なメールアドレス", "maxLength": "{length}文字以下でなければなりません", diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json index d4aac101e..6213bbf9d 100644 --- a/src/locales/ko/main.json +++ b/src/locales/ko/main.json @@ -17,6 +17,7 @@ "emailPlaceholder": "이메일을 입력하세요", "failed": "로그인 실패", "forgotPassword": "비밀번호를 잊으셨나요?", + "genericErrorMessage": "죄송합니다. 오류가 발생했습니다. {supportEmail}로 문의해 주세요.", "loginButton": "로그인", "loginWithGithub": "Github로 로그인", "loginWithGoogle": "구글로 로그인", @@ -25,12 +26,32 @@ "passwordLabel": "비밀번호", "passwordPlaceholder": "비밀번호를 입력하세요", "privacyLink": "개인정보 보호정책", + "signInOrSignUp": "로그인 / 회원가입", "signUp": "가입하기", "success": "로그인 성공", "termsLink": "이용 약관", "termsText": "\"다음\" 또는 \"가입하기\"를 클릭하면 우리의", "title": "계정에 로그인" }, + "required": { + "credits": { + "action": "설정에서 크레딧 추가 열기", + "hint": "크레딧을 추가하려면: 설정 > 사용자 > 크레딧 으로 이동하세요.", + "message": "이 워크플로에는 크레딧이 필요한 노드가 포함되어 있습니다. 계속하려면 계정에 크레딧을 추가하세요.", + "title": "워크플로 실행을 위한 크레딧 필요" + }, + "signIn": { + "action": "설정에서 로그인 열기", + "hint": "로그인하려면: 설정 > 사용자 > 로그인 으로 이동하세요.", + "message": "이 워크플로에는 활성 계정이 필요한 노드가 포함되어 있습니다. 계속하려면 로그인하거나 계정을 생성하세요.", + "title": "워크플로 실행을 위한 로그인 필요" + } + }, + "signOut": { + "signOut": "로그아웃", + "success": "성공적으로 로그아웃되었습니다", + "successDetail": "계정에서 로그아웃되었습니다." + }, "signup": { "alreadyHaveAccount": "이미 계정이 있으신가요?", "emailLabel": "이메일", @@ -96,9 +117,17 @@ "credits": "크레딧", "creditsHistory": "크레딧 내역", "faqs": "자주 묻는 질문", + "lastUpdated": "마지막 업데이트", "messageSupport": "지원 문의", - "paymentDetails": "결제 정보", "purchaseCredits": "크레딧 구매", + "topUp": { + "addCredits": "잔액에 크레딧 추가", + "buyNow": "지금 구매", + "insufficientMessage": "이 워크플로우를 실행하기에 크레딧이 부족합니다.", + "insufficientTitle": "크레딧 부족", + "maxAmount": "(최대 $1,000 USD)", + "title": "크레딧 잔액 충전" + }, "yourCreditBalance": "보유 크레딧 잔액" }, "dataTypes": { @@ -1096,6 +1125,17 @@ "next": "다음", "selectUser": "사용자 선택" }, + "userSettings": { + "email": "이메일", + "name": "이름", + "notSet": "설정되지 않음", + "provider": "로그인 방법", + "providers": { + "github": "GitHub", + "google": "Google" + }, + "title": "사용자 설정" + }, "validation": { "invalidEmail": "유효하지 않은 이메일 주소", "maxLength": "{length}자를 초과할 수 없습니다", diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json index c20d7dddd..debd3b99e 100644 --- a/src/locales/ru/main.json +++ b/src/locales/ru/main.json @@ -17,6 +17,7 @@ "emailPlaceholder": "Введите вашу электронную почту", "failed": "Вход не удался", "forgotPassword": "Забыли пароль?", + "genericErrorMessage": "Извините, произошла ошибка. Пожалуйста, свяжитесь с {supportEmail}.", "loginButton": "Войти", "loginWithGithub": "Войти через Github", "loginWithGoogle": "Войти через Google", @@ -25,12 +26,32 @@ "passwordLabel": "Пароль", "passwordPlaceholder": "Введите ваш пароль", "privacyLink": "Политикой конфиденциальности", + "signInOrSignUp": "Войти / Зарегистрироваться", "signUp": "Зарегистрироваться", "success": "Вход выполнен успешно", "termsLink": "Условиями использования", "termsText": "Нажимая \"Далее\" или \"Зарегистрироваться\", вы соглашаетесь с нашими", "title": "Войдите в свой аккаунт" }, + "required": { + "credits": { + "action": "Открыть настройки для пополнения кредитов", + "hint": "Чтобы пополнить кредиты, перейдите в: Настройки > Пользователь > Кредиты", + "message": "Этот рабочий процесс содержит узлы, для которых необходимы кредиты. Пожалуйста, пополните баланс, чтобы продолжить.", + "title": "Требуются кредиты для выполнения рабочего процесса" + }, + "signIn": { + "action": "Открыть настройки для входа", + "hint": "Чтобы войти, перейдите в: Настройки > Пользователь > Вход", + "message": "Этот рабочий процесс содержит узлы, для которых необходима активная учетная запись. Пожалуйста, войдите или создайте учетную запись, чтобы продолжить.", + "title": "Требуется вход для выполнения рабочего процесса" + } + }, + "signOut": { + "signOut": "Выйти", + "success": "Вы успешно вышли из системы", + "successDetail": "Вы вышли из своей учетной записи." + }, "signup": { "alreadyHaveAccount": "Уже есть аккаунт?", "emailLabel": "Электронная почта", @@ -96,9 +117,17 @@ "credits": "Кредиты", "creditsHistory": "История кредитов", "faqs": "Часто задаваемые вопросы", + "lastUpdated": "Последнее обновление", "messageSupport": "Связаться с поддержкой", - "paymentDetails": "Детали оплаты", "purchaseCredits": "Купить кредиты", + "topUp": { + "addCredits": "Добавить кредиты на баланс", + "buyNow": "Купить сейчас", + "insufficientMessage": "У вас недостаточно кредитов для запуска этого рабочего процесса.", + "insufficientTitle": "Недостаточно кредитов", + "maxAmount": "(Макс. $1,000 USD)", + "title": "Пополнить баланс кредитов" + }, "yourCreditBalance": "Ваш баланс кредитов" }, "dataTypes": { @@ -1096,6 +1125,17 @@ "next": "Далее", "selectUser": "Выберите пользователя" }, + "userSettings": { + "email": "Электронная почта", + "name": "Имя", + "notSet": "Не задано", + "provider": "Способ входа", + "providers": { + "github": "GitHub", + "google": "Google" + }, + "title": "Настройки пользователя" + }, "validation": { "invalidEmail": "Недействительный адрес электронной почты", "maxLength": "Должно быть не более {length} символов", diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json index 219594469..aebbc32ef 100644 --- a/src/locales/zh/main.json +++ b/src/locales/zh/main.json @@ -17,6 +17,7 @@ "emailPlaceholder": "输入您的电子邮件", "failed": "登录失败", "forgotPassword": "忘记密码?", + "genericErrorMessage": "抱歉,我们遇到了一些错误。请联系 {supportEmail}。", "loginButton": "登录", "loginWithGithub": "使用Github登录", "loginWithGoogle": "使用Google登录", @@ -25,12 +26,32 @@ "passwordLabel": "密码", "passwordPlaceholder": "输入您的密码", "privacyLink": "隐私政策", + "signInOrSignUp": "登录 / 注册", "signUp": "注册", "success": "登录成功", "termsLink": "使用条款", "termsText": "点击“下一步”或“注册”即表示您同意我们的", "title": "登录您的账户" }, + "required": { + "credits": { + "action": "打开设置进行充值", + "hint": "要充值,请前往:设置 > 用户 > 积分", + "message": "此工作流包含需要积分的节点。请为您的账户充值以继续。", + "title": "需要积分以执行工作流" + }, + "signIn": { + "action": "打开设置进行登录", + "hint": "要登录,请前往:设置 > 用户 > 登录", + "message": "此工作流包含需要活跃账户的节点。请登录或创建账户以继续。", + "title": "需要登录以执行工作流" + } + }, + "signOut": { + "signOut": "退出登录", + "success": "成功退出登录", + "successDetail": "您已成功退出账户。" + }, "signup": { "alreadyHaveAccount": "已经有账户了?", "emailLabel": "电子邮件", @@ -96,9 +117,17 @@ "credits": "积分", "creditsHistory": "积分历史", "faqs": "常见问题", + "lastUpdated": "最近更新", "messageSupport": "联系客服", - "paymentDetails": "支付详情", "purchaseCredits": "购买积分", + "topUp": { + "addCredits": "为您的余额充值", + "buyNow": "立即购买", + "insufficientMessage": "您的积分不足,无法运行此工作流。", + "insufficientTitle": "积分不足", + "maxAmount": "(最高 $1,000 美元)", + "title": "充值余额" + }, "yourCreditBalance": "您的积分余额" }, "dataTypes": { @@ -1096,6 +1125,17 @@ "next": "下一步", "selectUser": "选择用户" }, + "userSettings": { + "email": "电子邮件", + "name": "名称", + "notSet": "未设置", + "provider": "登录方式", + "providers": { + "github": "GitHub", + "google": "Google" + }, + "title": "用户设置" + }, "validation": { "invalidEmail": "无效的电子邮件地址", "maxLength": "不能超过{length}个字符", diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 598d34d7c..c8617e15d 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -673,7 +673,17 @@ export class ComfyApp { }) api.addEventListener('execution_error', ({ detail }) => { - useDialogService().showExecutionErrorDialog(detail) + // Check if this is an auth-related error or credits-related error + if (detail.exception_message === 'Please login first to use this node.') { + useDialogService().showSignInRequiredDialog({ type: 'signIn' }) + } else if ( + detail.exception_message === + 'Payment Required: Please add credits to your account to use this node.' + ) { + useDialogService().showSignInRequiredDialog({ type: 'credits' }) + } else { + useDialogService().showExecutionErrorDialog(detail) + } this.canvas.draw(true, true) }) diff --git a/src/services/dialogService.ts b/src/services/dialogService.ts index 03d327058..f835d8f90 100644 --- a/src/services/dialogService.ts +++ b/src/services/dialogService.ts @@ -8,6 +8,8 @@ import MissingModelsWarning from '@/components/dialog/content/MissingModelsWarni 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 SignInRequiredDialogContent from '@/components/dialog/content/SignInRequiredDialogContent.vue' +import TopUpCreditsDialogContent from '@/components/dialog/content/TopUpCreditsDialogContent.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' @@ -51,7 +53,13 @@ export const useDialogService = () => { } function showSettingsDialog( - panel?: 'about' | 'keybinding' | 'extension' | 'server-config' + panel?: + | 'about' + | 'keybinding' + | 'extension' + | 'server-config' + | 'user' + | 'credits' ) { const props = panel ? { props: { defaultPanel: panel } } : undefined @@ -340,6 +348,29 @@ export const useDialogService = () => { }) } + function showSignInRequiredDialog(options: { type: 'signIn' | 'credits' }) { + dialogStore.showDialog({ + key: 'signin-required', + component: SignInRequiredDialogContent, + props: options + }) + } + + function showTopUpCreditsDialog(options?: { + isInsufficientCredits?: boolean + }) { + return dialogStore.showDialog({ + key: 'top-up-credits', + component: TopUpCreditsDialogContent, + props: options, + dialogComponentProps: { + pt: { + header: { class: '!p-3' } + } + } + }) + } + return { showLoadWorkflowWarning, showMissingModelsWarning, @@ -353,6 +384,8 @@ export const useDialogService = () => { showErrorDialog, showApiNodesSignInDialog, showSignInDialog, + showSignInRequiredDialog, + showTopUpCreditsDialog, prompt, confirm } diff --git a/src/stores/firebaseAuthStore.ts b/src/stores/firebaseAuthStore.ts index c6d3147e5..aa41c7904 100644 --- a/src/stores/firebaseAuthStore.ts +++ b/src/stores/firebaseAuthStore.ts @@ -16,12 +16,24 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' import { useFirebaseAuth } from 'vuefire' +import { t } from '@/i18n' +import { useDialogService } from '@/services/dialogService' import { operations } from '@/types/comfyRegistryTypes' +import { useToastStore } from './toastStore' + type CreditPurchaseResponse = operations['InitiateCreditPurchase']['responses']['201']['content']['application/json'] type CreditPurchasePayload = operations['InitiateCreditPurchase']['requestBody']['content']['application/json'] +type CreateCustomerResponse = + operations['createCustomer']['responses']['201']['content']['application/json'] +type GetCustomerBalanceResponse = + operations['GetCustomerBalance']['responses']['200']['content']['application/json'] +type AccessBillingPortalResponse = + operations['AccessBillingPortal']['responses']['200']['content']['application/json'] +type AccessBillingPortalReqBody = + operations['AccessBillingPortal']['requestBody'] // TODO: Switch to prod api based on environment (requires prod api to be ready) const API_BASE_URL = 'https://stagingapi.comfy.org' @@ -32,6 +44,11 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { const error = ref(null) const currentUser = ref(null) const isInitialized = ref(false) + const customerCreated = ref(false) + + // Balance state + const balance = ref(null) + const lastBalanceUpdateTime = ref(null) // Providers const googleProvider = new GoogleAuthProvider() @@ -51,13 +68,92 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { onAuthStateChanged(auth, (user) => { currentUser.value = user isInitialized.value = true + + // Reset balance when auth state changes + balance.value = null + lastBalanceUpdateTime.value = null }) } else { error.value = 'Firebase Auth not available from VueFire' } + const showAuthErrorToast = () => { + useToastStore().add({ + summary: t('g.error'), + detail: t('auth.login.genericErrorMessage', { + supportEmail: 'support@comfy.org' + }), + severity: 'error' + }) + } + + const getIdToken = async (): Promise => { + if (currentUser.value) { + return currentUser.value.getIdToken() + } + return null + } + + const fetchBalance = async (): Promise => { + const token = await getIdToken() + if (!token) { + error.value = 'Cannot fetch balance: User not authenticated' + return null + } + + const response = await fetch(`${API_BASE_URL}/customers/balance`, { + headers: { + Authorization: `Bearer ${token}` + } + }) + + if (!response.ok) { + if (response.status === 404) { + // Customer not found is expected for new users + return null + } + const errorData = await response.json() + error.value = `Failed to fetch balance: ${errorData.message}` + return null + } + + const balanceData = await response.json() + // Update the last balance update time + lastBalanceUpdateTime.value = new Date() + balance.value = balanceData + return balanceData + } + + const createCustomer = async ( + token: string + ): Promise => { + const createCustomerRes = await fetch(`${API_BASE_URL}/customers`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + if (!createCustomerRes.ok) { + throw new Error( + `Failed to create customer: ${createCustomerRes.statusText}` + ) + } + + const createCustomerResJson: CreateCustomerResponse = + await createCustomerRes.json() + if (!createCustomerResJson?.id) { + throw new Error('Failed to create customer: No customer ID returned') + } + + return createCustomerResJson + } + const executeAuthAction = async ( - action: (auth: Auth) => Promise + action: (auth: Auth) => Promise, + options: { + createCustomer?: boolean + } = {} ): Promise => { if (!auth) throw new Error('Firebase Auth not initialized') @@ -65,9 +161,21 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { error.value = null try { - return await action(auth) + const result = await action(auth) + + // Create customer if needed + if (options?.createCustomer) { + const token = await getIdToken() + if (!token) { + throw new Error('Cannot create customer: User not authenticated') + } + await createCustomer(token) + } + + return result } catch (e: unknown) { error.value = e instanceof Error ? e.message : 'Unknown error' + showAuthErrorToast() throw e } finally { loading.value = false @@ -78,38 +186,38 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { email: string, password: string ): Promise => - executeAuthAction((authInstance) => - signInWithEmailAndPassword(authInstance, email, password) + executeAuthAction( + (authInstance) => + signInWithEmailAndPassword(authInstance, email, password), + { createCustomer: true } ) const register = async ( email: string, password: string - ): Promise => - executeAuthAction((authInstance) => - createUserWithEmailAndPassword(authInstance, email, password) + ): Promise => { + return executeAuthAction( + (authInstance) => + createUserWithEmailAndPassword(authInstance, email, password), + { createCustomer: true } ) + } const loginWithGoogle = async (): Promise => - executeAuthAction((authInstance) => - signInWithPopup(authInstance, googleProvider) + executeAuthAction( + (authInstance) => signInWithPopup(authInstance, googleProvider), + { createCustomer: true } ) const loginWithGithub = async (): Promise => - executeAuthAction((authInstance) => - signInWithPopup(authInstance, githubProvider) + executeAuthAction( + (authInstance) => signInWithPopup(authInstance, githubProvider), + { createCustomer: true } ) const logout = async (): Promise => executeAuthAction((authInstance) => signOut(authInstance)) - const getIdToken = async (): Promise => { - if (currentUser.value) { - return currentUser.value.getIdToken() - } - return null - } - const addCredits = async ( requestBodyContent: CreditPurchasePayload ): Promise => { @@ -119,6 +227,12 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { return null } + // Ensure customer was created during login/registration + if (!customerCreated.value) { + await createCustomer(token) + customerCreated.value = true + } + const response = await fetch(`${API_BASE_URL}/customers/credit`, { method: 'POST', headers: { @@ -134,7 +248,6 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { return null } - // TODO: start polling /listBalance until balance is updated or n retries fail or report no change return response.json() } @@ -143,12 +256,51 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { ): Promise => executeAuthAction((_) => addCredits(requestBodyContent)) + const openSignInPanel = () => { + useDialogService().showSettingsDialog('user') + } + + const openCreditsPanel = () => { + useDialogService().showSettingsDialog('credits') + } + + const accessBillingPortal = async ( + requestBody?: AccessBillingPortalReqBody + ): Promise => { + const token = await getIdToken() + if (!token) { + error.value = 'Cannot access billing portal: User not authenticated' + return null + } + + const response = await fetch(`${API_BASE_URL}/customers/billing`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + ...(requestBody && { + body: JSON.stringify(requestBody) + }) + }) + + if (!response.ok) { + const errorData = await response.json() + error.value = `Failed to access billing portal: ${errorData.message}` + return null + } + + return response.json() + } + return { // State loading, error, currentUser, isInitialized, + balance, + lastBalanceUpdateTime, // Getters isAuthenticated, @@ -162,6 +314,10 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { getIdToken, loginWithGoogle, loginWithGithub, - initiateCreditPurchase + initiateCreditPurchase, + openSignInPanel, + openCreditsPanel, + fetchBalance, + accessBillingPortal } }) diff --git a/src/utils/formatUtil.ts b/src/utils/formatUtil.ts index ce6766787..e4d04baaa 100644 --- a/src/utils/formatUtil.ts +++ b/src/utils/formatUtil.ts @@ -416,6 +416,49 @@ export function compareVersions( return 0 } +/** + * Converts a currency amount to Metronome's integer representation. + * For USD, converts to cents (multiplied by 100). + * For all other currencies (including custom pricing units), returns the amount as is. + * This is specific to Metronome's API requirements. + * + * @param amount - The amount in currency to convert + * @param currency - The currency to convert + * @returns The amount in Metronome's integer format (cents for USD, base units for others) + * @example + * toMetronomeCurrency(1.23, 'usd') // returns 123 (cents) + * toMetronomeCurrency(1000, 'jpy') // returns 1000 (yen) + */ +export function toMetronomeCurrency(amount: number, currency: string): number { + if (currency === 'usd') { + return Math.round(amount * 100) + } + return amount +} + +/** + * Converts Metronome's integer amount back to a formatted currency string. + * For USD, converts from cents to dollars. + * For all other currencies (including custom pricing units), returns the amount as is. + * This is specific to Metronome's API requirements. + * + * @param amount - The amount in Metronome's integer format (cents for USD, base units for others) + * @param currency - The currency to convert + * @returns The formatted amount in currency with 2 decimal places for USD + * @example + * formatMetronomeCurrency(123, 'usd') // returns "1.23" (cents to USD) + * formatMetronomeCurrency(1000, 'jpy') // returns "1000" (yen) + */ +export function formatMetronomeCurrency( + amount: number, + currency: string +): string { + if (currency === 'usd') { + return (amount / 100).toFixed(2) + } + return amount.toString() +} + /** * Converts a USD amount to microdollars (1/1,000,000 of a dollar). * This conversion is commonly used in financial systems to avoid floating-point precision issues diff --git a/tests-ui/tests/store/firebaseAuthStore.test.ts b/tests-ui/tests/store/firebaseAuthStore.test.ts index c8c80cd95..8dd4c0274 100644 --- a/tests-ui/tests/store/firebaseAuthStore.test.ts +++ b/tests-ui/tests/store/firebaseAuthStore.test.ts @@ -5,10 +5,47 @@ import * as vuefire from 'vuefire' import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' +// Mock fetch +const mockFetch = vi.fn() +vi.stubGlobal('fetch', mockFetch) + +// Mock successful API responses +const mockCreateCustomerResponse = { + ok: true, + statusText: 'OK', + json: () => Promise.resolve({ id: 'test-customer-id' }) +} + +const mockFetchBalanceResponse = { + ok: true, + json: () => Promise.resolve({ balance: 0 }) +} + +const mockAddCreditsResponse = { + ok: true, + statusText: 'OK' +} + +const mockAccessBillingPortalResponse = { + ok: true, + statusText: 'OK' +} + vi.mock('vuefire', () => ({ useFirebaseAuth: vi.fn() })) +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key + }), + createI18n: () => ({ + global: { + t: (key: string) => key + } + }) +})) + vi.mock('firebase/auth', () => ({ signInWithEmailAndPassword: vi.fn(), createUserWithEmailAndPassword: vi.fn(), @@ -21,6 +58,20 @@ vi.mock('firebase/auth', () => ({ setPersistence: vi.fn().mockResolvedValue(undefined) })) +// Mock useToastStore +vi.mock('@/stores/toastStore', () => ({ + useToastStore: () => ({ + add: vi.fn() + }) +})) + +// Mock useDialogService +vi.mock('@/services/dialogService', () => ({ + useDialogService: () => ({ + showSettingsDialog: vi.fn() + }) +})) + describe('useFirebaseAuthStore', () => { let store: ReturnType let authStateCallback: (user: any) => void @@ -52,9 +103,30 @@ describe('useFirebaseAuthStore', () => { } ) + // Mock fetch responses + mockFetch.mockImplementation((url: string) => { + if (url.endsWith('/customers')) { + return Promise.resolve(mockCreateCustomerResponse) + } + if (url.endsWith('/customers/balance')) { + return Promise.resolve(mockFetchBalanceResponse) + } + if (url.endsWith('/customers/credit')) { + return Promise.resolve(mockAddCreditsResponse) + } + if (url.endsWith('/customers/billing-portal')) { + return Promise.resolve(mockAccessBillingPortalResponse) + } + return Promise.reject(new Error('Unexpected API call')) + }) + // Initialize Pinia setActivePinia(createPinia()) store = useFirebaseAuthStore() + + // Reset and set up getIdToken mock + mockUser.getIdToken.mockReset() + mockUser.getIdToken.mockResolvedValue('mock-id-token') }) it('should initialize with the current user', () => { @@ -153,6 +225,23 @@ describe('useFirebaseAuthStore', () => { expect(store.loading).toBe(false) expect(store.error).toBe('Invalid password') }) + + 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('register', () => { @@ -192,23 +281,6 @@ describe('useFirebaseAuthStore', () => { 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', () => {