[Auth] Allow change password in user panel (#3699)

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Chenlei Hu
2025-04-30 16:12:57 -04:00
committed by GitHub
parent a43d1e1ee8
commit 834d5820d2
15 changed files with 305 additions and 169 deletions

View File

@@ -0,0 +1,47 @@
<template>
<Form
class="flex flex-col gap-6 w-96"
:resolver="zodResolver(updatePasswordSchema)"
@submit="onSubmit"
>
<PasswordFields />
<!-- Submit Button -->
<Button
type="submit"
:label="$t('userSettings.updatePassword')"
class="h-10 font-medium mt-4"
:loading="loading"
/>
</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 { ref } from 'vue'
import PasswordFields from '@/components/dialog/content/signin/PasswordFields.vue'
import { updatePasswordSchema } from '@/schemas/signInSchema'
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
const authService = useFirebaseAuthService()
const loading = ref(false)
const { onSuccess } = defineProps<{
onSuccess: () => void
}>()
const onSubmit = async (event: FormSubmitEvent) => {
if (event.valid) {
loading.value = true
try {
await authService.updatePassword(event.values.password)
onSuccess()
} finally {
loading.value = false
}
}
}
</script>

View File

@@ -38,6 +38,17 @@
<div class="text-muted flex items-center gap-1">
<i :class="providerIcon" />
{{ providerName }}
<Button
v-if="isEmailProvider"
v-tooltip="{
value: $t('userSettings.updatePassword'),
showDelay: 300
}"
icon="pi pi-pen-to-square"
severity="secondary"
text
@click="dialogService.showUpdatePasswordDialog()"
/>
</div>
</div>
@@ -83,9 +94,11 @@ import ProgressSpinner from 'primevue/progressspinner'
import TabPanel from 'primevue/tabpanel'
import { computed } from 'vue'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const dialogService = useDialogService()
const authStore = useFirebaseAuthStore()
const commandStore = useCommandStore()
const user = computed(() => authStore.currentUser)
@@ -113,6 +126,11 @@ const providerIcon = computed(() => {
return 'pi pi-user'
})
const isEmailProvider = computed(() => {
const providerId = user.value?.providerData[0]?.providerId
return providerId === 'password'
})
const handleSignOut = async () => {
await commandStore.execute('Comfy.User.SignOut')
}

View File

@@ -0,0 +1,111 @@
<template>
<!-- Password Field -->
<FormField v-slot="$field" name="password" 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': $field.invalid }"
fluid
class="h-10"
/>
<div class="flex flex-col gap-1">
<small v-if="$field.dirty || $field.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>
</FormField>
<!-- Confirm Password Field -->
<FormField v-slot="$field" name="confirmPassword" 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': $field.invalid }"
fluid
class="h-10"
/>
<small v-if="$field.error" class="text-red-500">{{
$field.error.message
}}</small>
</FormField>
</template>
<script setup lang="ts">
import { FormField } from '@primevue/forms'
import Password from 'primevue/password'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
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)
}))
</script>

View File

@@ -1,12 +1,11 @@
<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">
<FormField v-slot="$field" name="email" class="flex flex-col gap-2">
<label
class="opacity-80 text-base font-medium mb-2"
for="comfy-org-sign-up-email"
@@ -17,116 +16,27 @@
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"
:invalid="$field.invalid"
/>
<small v-if="$form.email?.invalid" class="text-red-500">{{
$form.email.error.message
<small v-if="$field.error" class="text-red-500">{{
$field.error.message
}}</small>
</div>
</FormField>
<!-- 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>
<PasswordFields />
<!-- Personal Data Consent Checkbox -->
<div class="flex items-center gap-2">
<FormField
v-slot="$field"
name="personalDataConsent"
class="flex items-center gap-2"
>
<Checkbox
input-id="comfy-org-sign-up-personal-data-consent"
name="personalDataConsent"
:binary="true"
:invalid="$form.personalDataConsent?.invalid"
:invalid="$field.invalid"
/>
<label
for="comfy-org-sign-up-personal-data-consent"
@@ -134,10 +44,10 @@
>
{{ t('auth.signup.personalDataConsentLabel') }}
</label>
</div>
<small v-if="$form.personalDataConsent?.error" class="text-red-500 -mt-4">{{
$form.personalDataConsent.error.message
}}</small>
<small v-if="$field.error" class="text-red-500 -mt-4">{{
$field.error.message
}}</small>
</FormField>
<!-- Submit Button -->
<Button
@@ -149,29 +59,18 @@
</template>
<script setup lang="ts">
import { Form, FormSubmitEvent } from '@primevue/forms'
import { Form, FormField, FormSubmitEvent } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'
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('')
import PasswordFields from './PasswordFields.vue'
// 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 { t } = useI18n()
const emit = defineEmits<{
submit: [values: SignUpData]

View File

@@ -1135,6 +1135,10 @@
"signOut": "Log Out",
"success": "Signed out successfully",
"successDetail": "You have been signed out of your account."
},
"passwordUpdate": {
"success": "Password Updated",
"successDetail": "Your password has been updated successfully"
}
},
"validation": {
@@ -1175,11 +1179,8 @@
"title": "User Settings",
"name": "Name",
"email": "Email",
"provider": "Sign-in Provider",
"notSet": "Not set",
"provider": "Sign in method",
"providers": {
"google": "Google",
"github": "GitHub"
}
"updatePassword": "Update Password"
}
}

View File

@@ -37,6 +37,10 @@
"termsText": "Al hacer clic en \"Siguiente\" o \"Registrarse\", aceptas nuestros",
"title": "Inicia sesión en tu cuenta"
},
"passwordUpdate": {
"success": "Contraseña actualizada",
"successDetail": "Tu contraseña se ha actualizado correctamente"
},
"signOut": {
"signOut": "Cerrar sesión",
"success": "Sesión cerrada correctamente",
@@ -1151,11 +1155,8 @@
"name": "Nombre",
"notSet": "No establecido",
"provider": "Método de inicio de sesión",
"providers": {
"github": "GitHub",
"google": "Google"
},
"title": "Configuración de usuario"
"title": "Configuración de usuario",
"updatePassword": "Actualizar contraseña"
},
"validation": {
"invalidEmail": "Dirección de correo electrónico inválida",

View File

@@ -37,6 +37,10 @@
"termsText": "En cliquant sur \"Suivant\" ou \"S'inscrire\", vous acceptez nos",
"title": "Connectez-vous à votre compte"
},
"passwordUpdate": {
"success": "Mot de passe mis à jour",
"successDetail": "Votre mot de passe a été mis à jour avec succès"
},
"signOut": {
"signOut": "Se déconnecter",
"success": "Déconnexion réussie",
@@ -1151,11 +1155,8 @@
"name": "Nom",
"notSet": "Non défini",
"provider": "Méthode de connexion",
"providers": {
"github": "GitHub",
"google": "Google"
},
"title": "Paramètres utilisateur"
"title": "Paramètres utilisateur",
"updatePassword": "Mettre à jour le mot de passe"
},
"validation": {
"invalidEmail": "Adresse e-mail invalide",

View File

@@ -37,6 +37,10 @@
"termsText": "「次へ」または「サインアップ」をクリックすると、私たちの",
"title": "アカウントにログインする"
},
"passwordUpdate": {
"success": "パスワードが更新されました",
"successDetail": "パスワードが正常に更新されました"
},
"signOut": {
"signOut": "ログアウト",
"success": "正常にサインアウトしました",
@@ -1151,11 +1155,8 @@
"name": "名前",
"notSet": "未設定",
"provider": "サインイン方法",
"providers": {
"github": "GitHub",
"google": "Google"
},
"title": "ユーザー設定"
"title": "ユーザー設定",
"updatePassword": "パスワードを更新"
},
"validation": {
"invalidEmail": "無効なメールアドレス",

View File

@@ -37,6 +37,10 @@
"termsText": "\"다음\" 또는 \"가입하기\"를 클릭하면 우리의",
"title": "계정에 로그인"
},
"passwordUpdate": {
"success": "비밀번호가 업데이트되었습니다",
"successDetail": "비밀번호가 성공적으로 업데이트되었습니다"
},
"signOut": {
"signOut": "로그아웃",
"success": "성공적으로 로그아웃되었습니다",
@@ -1151,11 +1155,8 @@
"name": "이름",
"notSet": "설정되지 않음",
"provider": "로그인 방법",
"providers": {
"github": "GitHub",
"google": "Google"
},
"title": "사용자 설정"
"title": "사용자 설정",
"updatePassword": "비밀번호 업데이트"
},
"validation": {
"invalidEmail": "유효하지 않은 이메일 주소",

View File

@@ -37,6 +37,10 @@
"termsText": "Нажимая \"Далее\" или \"Зарегистрироваться\", вы соглашаетесь с нашими",
"title": "Войдите в свой аккаунт"
},
"passwordUpdate": {
"success": "Пароль обновлён",
"successDetail": "Ваш пароль был успешно обновлён"
},
"signOut": {
"signOut": "Выйти",
"success": "Вы успешно вышли из системы",
@@ -1151,11 +1155,8 @@
"name": "Имя",
"notSet": "Не задано",
"provider": "Способ входа",
"providers": {
"github": "GitHub",
"google": "Google"
},
"title": "Настройки пользователя"
"title": "Настройки пользователя",
"updatePassword": "Обновить пароль"
},
"validation": {
"invalidEmail": "Недействительный адрес электронной почты",

View File

@@ -37,6 +37,10 @@
"termsText": "点击“下一步”或“注册”即表示您同意我们的",
"title": "登录您的账户"
},
"passwordUpdate": {
"success": "密码已更新",
"successDetail": "您的密码已成功更新"
},
"signOut": {
"signOut": "退出登录",
"success": "成功退出登录",
@@ -1151,11 +1155,8 @@
"name": "名称",
"notSet": "未设置",
"provider": "登录方式",
"providers": {
"github": "GitHub",
"google": "Google"
},
"title": "用户设置"
"title": "用户设置",
"updatePassword": "更新密码"
},
"validation": {
"invalidEmail": "无效的电子邮件地址",

View File

@@ -12,21 +12,34 @@ export const signInSchema = z.object({
export type SignInData = z.infer<typeof signInSchema>
export const signUpSchema = z
.object({
const passwordSchema = z.object({
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'))
})
export const updatePasswordSchema = passwordSchema.refine(
(data) => data.password === data.confirmPassword,
{
message: t('validation.password.match'),
path: ['confirmPassword']
}
)
export type UpdatePasswordData = z.infer<typeof updatePasswordSchema>
export const signUpSchema = passwordSchema
.extend({
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')),
personalDataConsent: z.boolean()
})
.refine((data) => data.password === data.confirmPassword, {

View File

@@ -9,6 +9,7 @@ import PromptDialogContent from '@/components/dialog/content/PromptDialogContent
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue'
import SignInContent from '@/components/dialog/content/SignInContent.vue'
import TopUpCreditsDialogContent from '@/components/dialog/content/TopUpCreditsDialogContent.vue'
import UpdatePasswordContent from '@/components/dialog/content/UpdatePasswordContent.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'
@@ -363,6 +364,21 @@ export const useDialogService = () => {
})
}
/**
* Shows a dialog for updating the current user's password.
*/
function showUpdatePasswordDialog() {
return dialogStore.showDialog({
key: 'global-update-password',
component: UpdatePasswordContent,
headerComponent: ComfyOrgHeader,
props: {
onSuccess: () =>
dialogStore.closeDialog({ key: 'global-update-password' })
}
})
}
return {
showLoadWorkflowWarning,
showMissingModelsWarning,
@@ -377,6 +393,7 @@ export const useDialogService = () => {
showApiNodesSignInDialog,
showSignInDialog,
showTopUpCreditsDialog,
showUpdatePasswordDialog,
prompt,
confirm
}

View File

@@ -118,6 +118,19 @@ export const useFirebaseAuthService = () => {
reportError
)
const updatePassword = wrapWithErrorHandlingAsync(
async (newPassword: string) => {
await authStore.updatePassword(newPassword)
toastStore.add({
severity: 'success',
summary: t('auth.passwordUpdate.success'),
detail: t('auth.passwordUpdate.successDetail'),
life: 5000
})
},
reportError
)
return {
logout,
sendPasswordReset,
@@ -127,6 +140,7 @@ export const useFirebaseAuthService = () => {
signInWithGoogle,
signInWithGithub,
signInWithEmail,
signUpWithEmail
signUpWithEmail,
updatePassword
}
}

View File

@@ -11,7 +11,8 @@ import {
setPersistence,
signInWithEmailAndPassword,
signInWithPopup,
signOut
signOut,
updatePassword
} from 'firebase/auth'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
@@ -224,6 +225,14 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
sendPasswordResetEmail(authInstance, email)
)
/** Update password for current user */
const _updatePassword = async (newPassword: string): Promise<void> => {
if (!currentUser.value) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
await updatePassword(currentUser.value, newPassword)
}
const addCredits = async (
requestBodyContent: CreditPurchasePayload
): Promise<CreditPurchaseResponse> => {
@@ -319,6 +328,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
initiateCreditPurchase,
fetchBalance,
accessBillingPortal,
sendPasswordReset
sendPasswordReset,
updatePassword: _updatePassword
}
})