frontend: normalize profile and admin i18n cleanup
This commit is contained in:
@@ -92,7 +92,7 @@ const avatarQualitySteps = [0.92, 0.84, 0.76, 0.68, 0.6, 0.52, 0.44, 0.36]
|
||||
const avatarDraft = ref('')
|
||||
const avatarSaving = ref(false)
|
||||
|
||||
const displayName = computed(() => props.user?.username?.trim() || props.user?.email?.trim() || 'User')
|
||||
const displayName = computed(() => props.user?.username?.trim() || props.user?.email?.trim() || t('profile.user'))
|
||||
const avatarInitial = computed(() => displayName.value.charAt(0).toUpperCase() || 'U')
|
||||
const avatarPreviewUrl = computed(() => avatarDraft.value.trim() || props.user?.avatar_url?.trim() || '')
|
||||
|
||||
|
||||
@@ -29,7 +29,11 @@
|
||||
<span
|
||||
:class="['badge', user?.status === 'active' ? 'badge-success' : 'badge-danger']"
|
||||
>
|
||||
{{ user?.status }}
|
||||
{{
|
||||
user?.status === 'active'
|
||||
? t('common.active')
|
||||
: t('common.disabled')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,7 +84,7 @@ const props = defineProps<{
|
||||
const { t } = useI18n()
|
||||
|
||||
const avatarUrl = computed(() => props.user?.avatar_url?.trim() || '')
|
||||
const displayName = computed(() => props.user?.username?.trim() || props.user?.email?.trim() || 'User')
|
||||
const displayName = computed(() => props.user?.username?.trim() || props.user?.email?.trim() || t('profile.user'))
|
||||
const avatarInitial = computed(() => displayName.value.charAt(0).toUpperCase() || 'U')
|
||||
|
||||
const providerLabels = computed<Record<UserAuthProvider, string>>(() => ({
|
||||
|
||||
@@ -50,12 +50,6 @@
|
||||
autocomplete="new-password"
|
||||
class="input"
|
||||
/>
|
||||
<p
|
||||
v-if="form.new_password && form.confirm_password && form.new_password !== form.confirm_password"
|
||||
class="input-error-text"
|
||||
>
|
||||
{{ t('profile.passwordsNotMatch') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4">
|
||||
|
||||
@@ -63,11 +63,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="error" class="rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('close')">
|
||||
@@ -104,7 +99,6 @@ const appStore = useAppStore()
|
||||
const methodLoading = ref(true)
|
||||
const verificationMethod = ref<'email' | 'password'>('password')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const sendingCode = ref(false)
|
||||
const codeCooldown = ref(0)
|
||||
const cooldownTimer = ref<ReturnType<typeof setInterval> | null>(null)
|
||||
@@ -164,7 +158,6 @@ const handleDisable = async () => {
|
||||
if (!canSubmit.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const request = verificationMethod.value === 'email'
|
||||
@@ -175,7 +168,7 @@ const handleDisable = async () => {
|
||||
appStore.showSuccess(t('profile.totp.disableSuccess'))
|
||||
emit('success')
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.message || t('profile.totp.disableFailed')
|
||||
appStore.showError(err.response?.data?.message || t('profile.totp.disableFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -61,10 +61,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="verifyError" class="rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||
{{ verifyError }}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button type="button" class="btn btn-secondary" @click="$emit('close')">
|
||||
{{ t('common.cancel') }}
|
||||
@@ -151,10 +147,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" class="btn btn-secondary" @click="step = 1">
|
||||
{{ t('common.back') }}
|
||||
@@ -195,7 +187,6 @@ const step = ref(0)
|
||||
const methodLoading = ref(true)
|
||||
const verificationMethod = ref<'email' | 'password'>('password')
|
||||
const verifyForm = ref({ emailCode: '', password: '' })
|
||||
const verifyError = ref('')
|
||||
const sendingCode = ref(false)
|
||||
const codeCooldown = ref(0)
|
||||
const cooldownTimer = ref<ReturnType<typeof setInterval> | null>(null)
|
||||
@@ -203,7 +194,6 @@ const cooldownTimer = ref<ReturnType<typeof setInterval> | null>(null)
|
||||
const setupLoading = ref(false)
|
||||
const setupData = ref<TotpSetupResponse | null>(null)
|
||||
const verifying = ref(false)
|
||||
const error = ref('')
|
||||
const code = ref<string[]>(['', '', '', '', '', ''])
|
||||
const inputRefs = ref<(HTMLInputElement | null)[]>([])
|
||||
const qrCodeDataUrl = ref('')
|
||||
@@ -361,7 +351,6 @@ const handleSendCode = async () => {
|
||||
|
||||
const handleVerifyAndSetup = async () => {
|
||||
setupLoading.value = true
|
||||
verifyError.value = ''
|
||||
|
||||
try {
|
||||
const request = verificationMethod.value === 'email'
|
||||
@@ -371,7 +360,7 @@ const handleVerifyAndSetup = async () => {
|
||||
setupData.value = await totpAPI.initiateSetup(request)
|
||||
step.value = 1
|
||||
} catch (err: any) {
|
||||
verifyError.value = err.response?.data?.message || t('profile.totp.setupFailed')
|
||||
appStore.showError(err.response?.data?.message || t('profile.totp.setupFailed'))
|
||||
} finally {
|
||||
setupLoading.value = false
|
||||
}
|
||||
@@ -382,7 +371,6 @@ const handleVerify = async () => {
|
||||
if (totpCode.length !== 6 || !setupData.value) return
|
||||
|
||||
verifying.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await totpAPI.enable({
|
||||
@@ -392,7 +380,7 @@ const handleVerify = async () => {
|
||||
appStore.showSuccess(t('profile.totp.enableSuccess'))
|
||||
emit('success')
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.message || t('profile.totp.verifyFailed')
|
||||
appStore.showError(err.response?.data?.message || t('profile.totp.verifyFailed'))
|
||||
code.value = ['', '', '', '', '', '']
|
||||
nextTick(() => {
|
||||
inputRefs.value[0]?.focus()
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import ProfilePasswordForm from '@/components/user/profile/ProfilePasswordForm.vue'
|
||||
|
||||
const { changePasswordMock, showSuccessMock, showErrorMock } = vi.hoisted(() => ({
|
||||
changePasswordMock: vi.fn(),
|
||||
showSuccessMock: vi.fn(),
|
||||
showErrorMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
userAPI: {
|
||||
changePassword: changePasswordMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showSuccess: showSuccessMock,
|
||||
showError: showErrorMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-i18n')>()
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'profile.changePassword': 'Change Password',
|
||||
'profile.currentPassword': 'Current Password',
|
||||
'profile.newPassword': 'New Password',
|
||||
'profile.confirmNewPassword': 'Confirm New Password',
|
||||
'profile.passwordHint': 'Password must be at least 8 characters long',
|
||||
'profile.changingPassword': 'Changing...',
|
||||
'profile.changePasswordButton': 'Change Password',
|
||||
'profile.passwordsNotMatch': 'New passwords do not match',
|
||||
'profile.passwordTooShort': 'Password must be at least 8 characters long',
|
||||
'profile.passwordChangeSuccess': 'Password changed successfully',
|
||||
'profile.passwordChangeFailed': 'Failed to change password'
|
||||
}
|
||||
return translations[key] ?? key
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('ProfilePasswordForm', () => {
|
||||
it('shows validation failures as toast messages instead of inline errors', async () => {
|
||||
const wrapper = mount(ProfilePasswordForm)
|
||||
|
||||
await wrapper.get('#old_password').setValue('old-password')
|
||||
await wrapper.get('#new_password').setValue('new-password')
|
||||
await wrapper.get('#confirm_password').setValue('different-password')
|
||||
await wrapper.get('form').trigger('submit.prevent')
|
||||
|
||||
expect(changePasswordMock).not.toHaveBeenCalled()
|
||||
expect(showErrorMock).toHaveBeenCalledWith('New passwords do not match')
|
||||
expect(wrapper.find('.input-error-text').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows API failures as toast messages', async () => {
|
||||
changePasswordMock.mockRejectedValue({
|
||||
response: { data: { detail: 'backend failure' } }
|
||||
})
|
||||
|
||||
const wrapper = mount(ProfilePasswordForm)
|
||||
|
||||
await wrapper.get('#old_password').setValue('old-password')
|
||||
await wrapper.get('#new_password').setValue('new-password')
|
||||
await wrapper.get('#confirm_password').setValue('new-password')
|
||||
await wrapper.get('form').trigger('submit.prevent')
|
||||
|
||||
expect(changePasswordMock).toHaveBeenCalledWith('old-password', 'new-password')
|
||||
expect(showErrorMock).toHaveBeenCalledWith('backend failure')
|
||||
expect(wrapper.find('.input-error-text').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,10 @@ const mocks = vi.hoisted(() => ({
|
||||
showSuccess: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
getVerificationMethod: vi.fn(),
|
||||
sendVerifyCode: vi.fn()
|
||||
sendVerifyCode: vi.fn(),
|
||||
initiateSetup: vi.fn(),
|
||||
enable: vi.fn(),
|
||||
disable: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
@@ -27,9 +30,9 @@ vi.mock('@/api', () => ({
|
||||
totpAPI: {
|
||||
getVerificationMethod: mocks.getVerificationMethod,
|
||||
sendVerifyCode: mocks.sendVerifyCode,
|
||||
initiateSetup: vi.fn(),
|
||||
enable: vi.fn(),
|
||||
disable: vi.fn()
|
||||
initiateSetup: mocks.initiateSetup,
|
||||
enable: mocks.enable,
|
||||
disable: mocks.disable
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -49,9 +52,19 @@ describe('TOTP 弹窗定时器清理', () => {
|
||||
mocks.showError.mockReset()
|
||||
mocks.getVerificationMethod.mockReset()
|
||||
mocks.sendVerifyCode.mockReset()
|
||||
mocks.initiateSetup.mockReset()
|
||||
mocks.enable.mockReset()
|
||||
mocks.disable.mockReset()
|
||||
|
||||
mocks.getVerificationMethod.mockResolvedValue({ method: 'email' })
|
||||
mocks.sendVerifyCode.mockResolvedValue({ success: true })
|
||||
mocks.initiateSetup.mockResolvedValue({
|
||||
qr_code_url: 'otpauth://totp/Sub2API:test?secret=ABC123',
|
||||
secret: 'ABC123',
|
||||
setup_token: 'setup-token'
|
||||
})
|
||||
mocks.enable.mockResolvedValue({ success: true })
|
||||
mocks.disable.mockResolvedValue({ success: true })
|
||||
|
||||
setIntervalSpy = vi.spyOn(window, 'setInterval').mockImplementation(((handler: TimerHandler) => {
|
||||
void handler
|
||||
@@ -105,4 +118,40 @@ describe('TOTP 弹窗定时器清理', () => {
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalledWith(timerId)
|
||||
})
|
||||
|
||||
it('TotpSetupModal 失败时改用 toast 并不渲染内联错误', async () => {
|
||||
mocks.getVerificationMethod.mockResolvedValue({ method: 'password' })
|
||||
mocks.initiateSetup.mockRejectedValue({
|
||||
response: { data: { message: 'setup failed' } }
|
||||
})
|
||||
|
||||
const wrapper = mount(TotpSetupModal)
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('input[type="password"]').setValue('correct horse battery staple')
|
||||
await wrapper.get('button[type="button"].btn-primary').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.showError).toHaveBeenCalledWith('setup failed')
|
||||
expect(wrapper.text()).not.toContain('setup failed')
|
||||
expect(wrapper.find('.bg-red-50').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('TotpDisableDialog 失败时改用 toast 并不渲染内联错误', async () => {
|
||||
mocks.getVerificationMethod.mockResolvedValue({ method: 'password' })
|
||||
mocks.disable.mockRejectedValue({
|
||||
response: { data: { message: 'disable failed' } }
|
||||
})
|
||||
|
||||
const wrapper = mount(TotpDisableDialog)
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('input[type="password"]').setValue('correct horse battery staple')
|
||||
await wrapper.get('form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.showError).toHaveBeenCalledWith('disable failed')
|
||||
expect(wrapper.text()).not.toContain('disable failed')
|
||||
expect(wrapper.find('.bg-red-50').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user