mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-20 00:18:55 +00:00
Merge pull request #6654 from ouchihao/codex/issue-2229-register-confirm-password
feat(auth): require password confirmation during registration
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user