Merge pull request #6654 from ouchihao/codex/issue-2229-register-confirm-password

feat(auth): require password confirmation during registration
This commit is contained in:
Wesley Liddick
2026-09-15 08:56:56 +08:00
committed by GitHub
2 changed files with 136 additions and 5 deletions
+46
View File
@@ -87,6 +87,38 @@
</p>
</div>
<!-- Confirm Password Input -->
<div>
<label for="confirmPassword" class="input-label">
{{ t('auth.confirmPassword') }}
</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<Icon name="lock" size="md" class="text-gray-400 dark:text-dark-500" />
</div>
<input
id="confirmPassword"
v-model="confirmPassword"
:type="showConfirmPassword ? 'text' : 'password'"
required
autocomplete="new-password"
:disabled="registrationActionDisabled"
class="input pl-11 pr-11"
:class="{ 'input-error': errors.confirmPassword }"
:placeholder="t('auth.confirmPasswordPlaceholder')"
/>
<button
type="button"
:disabled="registrationActionDisabled"
@click="showConfirmPassword = !showConfirmPassword"
class="absolute inset-y-0 right-0 flex items-center pr-3.5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-dark-300"
>
<Icon v-if="showConfirmPassword" name="eyeOff" size="md" />
<Icon v-else name="eye" size="md" />
</button>
</div>
</div>
<!-- Invitation Code Input (Required when enabled) -->
<div v-if="invitationCodeEnabled">
<label for="invitation_code" class="input-label">
@@ -384,6 +416,8 @@ const isLoading = ref<boolean>(false)
const settingsLoaded = ref<boolean>(false)
const errorMessage = ref<string>('')
const showPassword = ref<boolean>(false)
const showConfirmPassword = ref<boolean>(false)
const confirmPassword = ref('')
// Public settings
const registrationEnabled = ref<boolean>(true)
@@ -469,6 +503,7 @@ const formData = reactive({
const errors = reactive({
email: '',
password: '',
confirmPassword: '',
turnstile: '',
invitation_code: ''
})
@@ -476,6 +511,7 @@ const errors = reactive({
const validationToastMessage = computed(() =>
errors.email ||
errors.password ||
errors.confirmPassword ||
(invitationValidation.invalid ? invitationValidation.message : '') ||
errors.invitation_code ||
(promoValidation.invalid ? promoValidation.message : '') ||
@@ -884,6 +920,7 @@ function validateForm(): boolean {
// Reset errors
errors.email = ''
errors.password = ''
errors.confirmPassword = ''
errors.turnstile = ''
errors.invitation_code = ''
@@ -922,6 +959,15 @@ function validateForm(): boolean {
isValid = false
}
// Confirm password validation
if (!confirmPassword.value) {
errors.confirmPassword = t('auth.confirmPasswordRequired')
isValid = false
} else if (formData.password !== confirmPassword.value) {
errors.confirmPassword = t('auth.passwordsDoNotMatch')
isValid = false
}
// Invitation code validation (required when enabled)
if (invitationCodeEnabled.value) {
if (!formData.invitation_code.trim()) {
@@ -2,10 +2,12 @@ import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import RegisterView from '@/views/auth/RegisterView.vue'
const { getPublicSettingsMock, registerMock, showErrorMock } = vi.hoisted(() => ({
const { getPublicSettingsMock, registerMock, showErrorMock, pushMock, verifyActionMock } = vi.hoisted(() => ({
getPublicSettingsMock: vi.fn(),
registerMock: vi.fn(),
showErrorMock: vi.fn()
showErrorMock: vi.fn(),
pushMock: vi.fn(),
verifyActionMock: vi.fn()
}))
const publicSettings = {
@@ -26,7 +28,7 @@ const publicSettings = {
}
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
useRouter: () => ({ push: pushMock }),
useRoute: () => ({ query: {} })
}))
@@ -68,7 +70,10 @@ function mountRegister() {
stubs: {
AuthLayout: { template: '<div><slot /><slot name="footer" /></div>' },
Icon: true,
TurnstileWidget: { template: '<div data-testid="turnstile-widget" />' },
TurnstileWidget: {
template: '<div data-testid="turnstile-widget" />',
methods: { verifyAction: verifyActionMock, reset: vi.fn() }
},
LoginAgreementPrompt: true,
EmailOAuthButtons: true,
LinuxDoOAuthSection: true,
@@ -81,15 +86,91 @@ function mountRegister() {
})
}
describe('RegisterView invitation layout', () => {
describe('RegisterView', () => {
beforeEach(() => {
getPublicSettingsMock.mockReset()
registerMock.mockReset()
showErrorMock.mockReset()
pushMock.mockReset()
verifyActionMock.mockReset()
sessionStorage.removeItem('register_data')
verifyActionMock.mockResolvedValue({ token: 'ticket', randstr: 'randstr' })
getPublicSettingsMock.mockResolvedValue(publicSettings)
registerMock.mockResolvedValue({})
})
it.each([
['', 'auth.confirmPasswordRequired'],
['different-password', 'auth.passwordsDoNotMatch']
])('blocks invalid confirmation %j before captcha and allows correction', async (confirmation, error) => {
getPublicSettingsMock.mockResolvedValueOnce({
...publicSettings,
turnstile_enabled: false,
tencent_captcha_enabled: true,
tencent_captcha_app_id: 'app-id'
})
const wrapper = mountRegister()
await flushPromises()
await wrapper.get('#email').setValue('user@example.com')
await wrapper.get('#password').setValue('secret-123')
await wrapper.get('#confirmPassword').setValue(confirmation)
await wrapper.get('form').trigger('submit.prevent')
await flushPromises()
expect(showErrorMock).toHaveBeenCalledWith(error)
expect(wrapper.get('#confirmPassword').classes()).toContain('input-error')
expect(registerMock).not.toHaveBeenCalled()
expect(verifyActionMock).not.toHaveBeenCalled()
expect(pushMock).not.toHaveBeenCalled()
expect(sessionStorage.getItem('register_data')).toBeNull()
await wrapper.get('#confirmPassword').setValue('secret-123')
await wrapper.get('form').trigger('submit.prevent')
await flushPromises()
expect(wrapper.get('#confirmPassword').classes()).not.toContain('input-error')
expect(verifyActionMock).toHaveBeenCalledOnce()
expect(registerMock).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'secret-123',
turnstile_token: undefined,
tencent_captcha_ticket: 'ticket',
tencent_captcha_randstr: 'randstr',
promo_code: undefined,
invitation_code: undefined
})
expect(pushMock).toHaveBeenCalledWith('/dashboard')
})
it('requires matching confirmation before storing only the registration fields for email verification', async () => {
getPublicSettingsMock.mockResolvedValueOnce({
...publicSettings,
turnstile_enabled: false,
email_verify_enabled: true
})
const wrapper = mountRegister()
await flushPromises()
await wrapper.get('#email').setValue('user@example.com')
await wrapper.get('#password').setValue('secret-123')
await wrapper.get('#confirmPassword').setValue('different-password')
await wrapper.get('form').trigger('submit.prevent')
await flushPromises()
expect(sessionStorage.getItem('register_data')).toBeNull()
expect(pushMock).not.toHaveBeenCalled()
await wrapper.get('#confirmPassword').setValue('secret-123')
await wrapper.get('form').trigger('submit.prevent')
await flushPromises()
expect(JSON.parse(sessionStorage.getItem('register_data')!)).toEqual({
email: 'user@example.com',
password: 'secret-123'
})
expect(pushMock).toHaveBeenCalledWith('/email-verify')
expect(registerMock).not.toHaveBeenCalled()
})
it('keeps the optional affiliate invitation field before Turnstile', async () => {
const wrapper = mountRegister()
await flushPromises()
@@ -130,6 +211,7 @@ describe('RegisterView invitation layout', () => {
await flushPromises()
await wrapper.get('#email').setValue('first@custom.example')
await wrapper.get('#password').setValue('secret-123')
await wrapper.get('#confirmPassword').setValue('secret-123')
await wrapper.get('form').trigger('submit.prevent')
await flushPromises()
@@ -155,6 +237,7 @@ describe('RegisterView invitation layout', () => {
await flushPromises()
await wrapper.get('#email').setValue('second@custom.example')
await wrapper.get('#password').setValue('secret-123')
await wrapper.get('#confirmPassword').setValue('secret-123')
await wrapper.get('form').trigger('submit.prevent')
await flushPromises()
@@ -175,6 +258,7 @@ describe('RegisterView invitation layout', () => {
await flushPromises()
await wrapper.get('#email').setValue('first@custom.example')
await wrapper.get('#password').setValue('secret-123')
await wrapper.get('#confirmPassword').setValue('secret-123')
await wrapper.get('form').trigger('submit.prevent')
await flushPromises()
@@ -195,6 +279,7 @@ describe('RegisterView invitation layout', () => {
await flushPromises()
await wrapper.get('#email').setValue('user@allowed.com')
await wrapper.get('#password').setValue('secret-123')
await wrapper.get('#confirmPassword').setValue('secret-123')
await wrapper.get('form').trigger('submit.prevent')
await flushPromises()