fix(web): require password confirmation in auth forms

Require users to enter new passwords twice in the registration
and password change forms so typos are caught before credentials
are stored.
This commit is contained in:
fanyang
2026-04-05 17:28:50 +08:00
parent 2490bb9808
commit 7707b1cf5e
4 changed files with 60 additions and 2 deletions
@@ -366,6 +366,7 @@ web:
password_empty: 密码不能为空 password_empty: 密码不能为空
password_min_length: 密码至少需要 8 位 password_min_length: 密码至少需要 8 位
password_too_weak: 密码强度不足 password_too_weak: 密码强度不足
password_mismatch: 两次输入的密码不一致
password_strength_hint: 密码至少 8 位,且需包含大小写字母、数字、特殊字符中的至少 2 类 password_strength_hint: 密码至少 8 位,且需包含大小写字母、数字、特殊字符中的至少 2 类
enable: 开启 enable: 开启
disable: 关闭 disable: 关闭
@@ -366,6 +366,7 @@ web:
password_empty: Password cannot be empty password_empty: Password cannot be empty
password_min_length: Password must be at least 8 characters long password_min_length: Password must be at least 8 characters long
password_too_weak: Password is too weak password_too_weak: Password is too weak
password_mismatch: Passwords do not match
password_strength_hint: Password must be at least 8 characters and include at least 2 of uppercase letters, lowercase letters, numbers, or special characters password_strength_hint: Password must be at least 8 characters and include at least 2 of uppercase letters, lowercase letters, numbers, or special characters
enable: Enable enable: Enable
disable: Disable disable: Disable
@@ -13,10 +13,12 @@ const dialogRef = inject<any>('dialogRef');
const api = computed<ApiClient>(() => dialogRef.value.data.api); const api = computed<ApiClient>(() => dialogRef.value.data.api);
const password = ref(''); const password = ref('');
const confirmPassword = ref('');
const toast = useToast(); const toast = useToast();
const router = useRouter(); const router = useRouter();
const { t } = useI18n(); const { t } = useI18n();
const passwordValidation = computed(() => validatePasswordStrength(password.value)); const passwordValidation = computed(() => validatePasswordStrength(password.value));
const passwordMatches = computed(() => password.value === confirmPassword.value);
const passwordErrorMessage = computed(() => { const passwordErrorMessage = computed(() => {
if (password.value.length === 0 || passwordValidation.value.valid) { if (password.value.length === 0 || passwordValidation.value.valid) {
return ''; return '';
@@ -24,6 +26,14 @@ const passwordErrorMessage = computed(() => {
return t(passwordValidation.value.reasonKey!); return t(passwordValidation.value.reasonKey!);
}); });
const confirmPasswordErrorMessage = computed(() => {
if (confirmPassword.value.length === 0 || passwordMatches.value) {
return '';
}
return t('web.common.password_mismatch');
});
const canSubmit = computed(() => passwordValidation.value.valid && passwordMatches.value);
const changePassword = async () => { const changePassword = async () => {
if (!passwordValidation.value.valid) { if (!passwordValidation.value.valid) {
@@ -36,6 +46,16 @@ const changePassword = async () => {
return; return;
} }
if (!passwordMatches.value) {
toast.add({
severity: 'warn',
summary: t('web.common.warning'),
detail: t('web.common.password_mismatch'),
life: 3000,
});
return;
}
try { try {
await api.value.change_password(password.value); await api.value.change_password(password.value);
toast.add({ toast.add({
@@ -69,14 +89,19 @@ const changePassword = async () => {
<div class="flex flex-col space-y-4"> <div class="flex flex-col space-y-4">
<Password v-model="password" :placeholder="t('web.settings.new_password')" :feedback="false" <Password v-model="password" :placeholder="t('web.settings.new_password')" :feedback="false"
toggleMask /> toggleMask />
<Password v-model="confirmPassword" :placeholder="t('web.settings.confirm_password')"
:feedback="false" toggleMask />
<small class="text-surface-500 dark:text-surface-400"> <small class="text-surface-500 dark:text-surface-400">
{{ t('web.common.password_strength_hint') }} {{ t('web.common.password_strength_hint') }}
</small> </small>
<small v-if="passwordErrorMessage" class="text-red-500 dark:text-red-400"> <small v-if="passwordErrorMessage" class="text-red-500 dark:text-red-400">
{{ passwordErrorMessage }} {{ passwordErrorMessage }}
</small> </small>
<small v-if="confirmPasswordErrorMessage" class="text-red-500 dark:text-red-400">
{{ confirmPasswordErrorMessage }}
</small>
<Button @click="changePassword" :label="t('web.common.confirm')" <Button @click="changePassword" :label="t('web.common.confirm')"
:disabled="!passwordValidation.valid" /> :disabled="!canSubmit" />
</div> </div>
</template> </template>
</Card> </Card>
+32 -1
View File
@@ -24,9 +24,11 @@ const username = ref('');
const password = ref(''); const password = ref('');
const registerUsername = ref(''); const registerUsername = ref('');
const registerPassword = ref(''); const registerPassword = ref('');
const registerConfirmPassword = ref('');
const captcha = ref(''); const captcha = ref('');
const captchaSrc = computed(() => api.value.captcha_url()); const captchaSrc = computed(() => api.value.captcha_url());
const registerPasswordValidation = computed(() => validatePasswordStrength(registerPassword.value)); const registerPasswordValidation = computed(() => validatePasswordStrength(registerPassword.value));
const registerPasswordsMatch = computed(() => registerPassword.value === registerConfirmPassword.value);
const registerPasswordErrorMessage = computed(() => { const registerPasswordErrorMessage = computed(() => {
if (registerPassword.value.length === 0 || registerPasswordValidation.value.valid) { if (registerPassword.value.length === 0 || registerPasswordValidation.value.valid) {
return ''; return '';
@@ -34,6 +36,14 @@ const registerPasswordErrorMessage = computed(() => {
return t(registerPasswordValidation.value.reasonKey!); return t(registerPasswordValidation.value.reasonKey!);
}); });
const registerConfirmPasswordErrorMessage = computed(() => {
if (registerConfirmPassword.value.length === 0 || registerPasswordsMatch.value) {
return '';
}
return t('web.common.password_mismatch');
});
const canRegister = computed(() => registerPasswordValidation.value.valid && registerPasswordsMatch.value);
const onSubmit = async () => { const onSubmit = async () => {
@@ -64,6 +74,16 @@ const onRegister = async () => {
return; return;
} }
if (!registerPasswordsMatch.value) {
toast.add({
severity: 'warn',
summary: t('web.common.warning'),
detail: t('web.common.password_mismatch'),
life: 3000,
});
return;
}
saveApiHost(apiHost.value); saveApiHost(apiHost.value);
const credential: Credential = { username: registerUsername.value, password: registerPassword.value }; const credential: Credential = { username: registerUsername.value, password: registerPassword.value };
const registerReq: RegisterData = { credentials: credential, captcha: captcha.value }; const registerReq: RegisterData = { credentials: credential, captcha: captcha.value };
@@ -184,6 +204,17 @@ onBeforeUnmount(() => {
{{ registerPasswordErrorMessage }} {{ registerPasswordErrorMessage }}
</small> </small>
</div> </div>
<div class="p-field">
<label for="register-confirm-password" class="block text-sm font-medium">
{{ t('web.settings.confirm_password') }}
</label>
<Password id="register-confirm-password" v-model="registerConfirmPassword" required toggleMask
:feedback="false" class="w-full" />
<small v-if="registerConfirmPasswordErrorMessage"
class="block text-red-500 dark:text-red-400">
{{ registerConfirmPasswordErrorMessage }}
</small>
</div>
<div class="p-field"> <div class="p-field">
<label for="captcha" class="block text-sm font-medium">{{ t('web.login.captcha') }}</label> <label for="captcha" class="block text-sm font-medium">{{ t('web.login.captcha') }}</label>
<InputText id="captcha" v-model="captcha" required class="w-full" /> <InputText id="captcha" v-model="captcha" required class="w-full" />
@@ -191,7 +222,7 @@ onBeforeUnmount(() => {
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Button :label="t('web.login.register')" type="submit" class="w-full" <Button :label="t('web.login.register')" type="submit" class="w-full"
:disabled="!registerPasswordValidation.valid" /> :disabled="!canRegister" />
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Button :label="t('web.login.back_to_login')" type="button" class="w-full" <Button :label="t('web.login.back_to_login')" type="button" class="w-full"