Commit 20062b44 authored by IanShaw027's avatar IanShaw027
Browse files

frontend: normalize profile and admin i18n cleanup

parent a6b919eb
...@@ -92,7 +92,7 @@ const avatarQualitySteps = [0.92, 0.84, 0.76, 0.68, 0.6, 0.52, 0.44, 0.36] ...@@ -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 avatarDraft = ref('')
const avatarSaving = ref(false) 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 avatarInitial = computed(() => displayName.value.charAt(0).toUpperCase() || 'U')
const avatarPreviewUrl = computed(() => avatarDraft.value.trim() || props.user?.avatar_url?.trim() || '') const avatarPreviewUrl = computed(() => avatarDraft.value.trim() || props.user?.avatar_url?.trim() || '')
......
...@@ -29,7 +29,11 @@ ...@@ -29,7 +29,11 @@
<span <span
:class="['badge', user?.status === 'active' ? 'badge-success' : 'badge-danger']" :class="['badge', user?.status === 'active' ? 'badge-success' : 'badge-danger']"
> >
{{ user?.status }} {{
user?.status === 'active'
? t('common.active')
: t('common.disabled')
}}
</span> </span>
</div> </div>
</div> </div>
...@@ -80,7 +84,7 @@ const props = defineProps<{ ...@@ -80,7 +84,7 @@ const props = defineProps<{
const { t } = useI18n() const { t } = useI18n()
const avatarUrl = computed(() => props.user?.avatar_url?.trim() || '') 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 avatarInitial = computed(() => displayName.value.charAt(0).toUpperCase() || 'U')
const providerLabels = computed<Record<UserAuthProvider, string>>(() => ({ const providerLabels = computed<Record<UserAuthProvider, string>>(() => ({
......
...@@ -50,12 +50,6 @@ ...@@ -50,12 +50,6 @@
autocomplete="new-password" autocomplete="new-password"
class="input" 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>
<div class="flex justify-end pt-4"> <div class="flex justify-end pt-4">
......
...@@ -63,11 +63,6 @@ ...@@ -63,11 +63,6 @@
/> />
</div> </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 --> <!-- Actions -->
<div class="flex justify-end gap-3 pt-4"> <div class="flex justify-end gap-3 pt-4">
<button type="button" class="btn btn-secondary" @click="$emit('close')"> <button type="button" class="btn btn-secondary" @click="$emit('close')">
...@@ -104,7 +99,6 @@ const appStore = useAppStore() ...@@ -104,7 +99,6 @@ const appStore = useAppStore()
const methodLoading = ref(true) const methodLoading = ref(true)
const verificationMethod = ref<'email' | 'password'>('password') const verificationMethod = ref<'email' | 'password'>('password')
const loading = ref(false) const loading = ref(false)
const error = ref('')
const sendingCode = ref(false) const sendingCode = ref(false)
const codeCooldown = ref(0) const codeCooldown = ref(0)
const cooldownTimer = ref<ReturnType<typeof setInterval> | null>(null) const cooldownTimer = ref<ReturnType<typeof setInterval> | null>(null)
...@@ -164,7 +158,6 @@ const handleDisable = async () => { ...@@ -164,7 +158,6 @@ const handleDisable = async () => {
if (!canSubmit.value) return if (!canSubmit.value) return
loading.value = true loading.value = true
error.value = ''
try { try {
const request = verificationMethod.value === 'email' const request = verificationMethod.value === 'email'
...@@ -175,7 +168,7 @@ const handleDisable = async () => { ...@@ -175,7 +168,7 @@ const handleDisable = async () => {
appStore.showSuccess(t('profile.totp.disableSuccess')) appStore.showSuccess(t('profile.totp.disableSuccess'))
emit('success') emit('success')
} catch (err: any) { } catch (err: any) {
error.value = err.response?.data?.message || t('profile.totp.disableFailed') appStore.showError(err.response?.data?.message || t('profile.totp.disableFailed'))
} finally { } finally {
loading.value = false loading.value = false
} }
......
...@@ -61,10 +61,6 @@ ...@@ -61,10 +61,6 @@
</div> </div>
</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"> <div class="flex justify-end gap-3 pt-4">
<button type="button" class="btn btn-secondary" @click="$emit('close')"> <button type="button" class="btn btn-secondary" @click="$emit('close')">
{{ t('common.cancel') }} {{ t('common.cancel') }}
...@@ -151,10 +147,6 @@ ...@@ -151,10 +147,6 @@
</div> </div>
</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"> <div class="flex justify-end gap-3">
<button type="button" class="btn btn-secondary" @click="step = 1"> <button type="button" class="btn btn-secondary" @click="step = 1">
{{ t('common.back') }} {{ t('common.back') }}
...@@ -195,7 +187,6 @@ const step = ref(0) ...@@ -195,7 +187,6 @@ const step = ref(0)
const methodLoading = ref(true) const methodLoading = ref(true)
const verificationMethod = ref<'email' | 'password'>('password') const verificationMethod = ref<'email' | 'password'>('password')
const verifyForm = ref({ emailCode: '', password: '' }) const verifyForm = ref({ emailCode: '', password: '' })
const verifyError = ref('')
const sendingCode = ref(false) const sendingCode = ref(false)
const codeCooldown = ref(0) const codeCooldown = ref(0)
const cooldownTimer = ref<ReturnType<typeof setInterval> | null>(null) const cooldownTimer = ref<ReturnType<typeof setInterval> | null>(null)
...@@ -203,7 +194,6 @@ 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 setupLoading = ref(false)
const setupData = ref<TotpSetupResponse | null>(null) const setupData = ref<TotpSetupResponse | null>(null)
const verifying = ref(false) const verifying = ref(false)
const error = ref('')
const code = ref<string[]>(['', '', '', '', '', '']) const code = ref<string[]>(['', '', '', '', '', ''])
const inputRefs = ref<(HTMLInputElement | null)[]>([]) const inputRefs = ref<(HTMLInputElement | null)[]>([])
const qrCodeDataUrl = ref('') const qrCodeDataUrl = ref('')
...@@ -361,7 +351,6 @@ const handleSendCode = async () => { ...@@ -361,7 +351,6 @@ const handleSendCode = async () => {
const handleVerifyAndSetup = async () => { const handleVerifyAndSetup = async () => {
setupLoading.value = true setupLoading.value = true
verifyError.value = ''
try { try {
const request = verificationMethod.value === 'email' const request = verificationMethod.value === 'email'
...@@ -371,7 +360,7 @@ const handleVerifyAndSetup = async () => { ...@@ -371,7 +360,7 @@ const handleVerifyAndSetup = async () => {
setupData.value = await totpAPI.initiateSetup(request) setupData.value = await totpAPI.initiateSetup(request)
step.value = 1 step.value = 1
} catch (err: any) { } catch (err: any) {
verifyError.value = err.response?.data?.message || t('profile.totp.setupFailed') appStore.showError(err.response?.data?.message || t('profile.totp.setupFailed'))
} finally { } finally {
setupLoading.value = false setupLoading.value = false
} }
...@@ -382,7 +371,6 @@ const handleVerify = async () => { ...@@ -382,7 +371,6 @@ const handleVerify = async () => {
if (totpCode.length !== 6 || !setupData.value) return if (totpCode.length !== 6 || !setupData.value) return
verifying.value = true verifying.value = true
error.value = ''
try { try {
await totpAPI.enable({ await totpAPI.enable({
...@@ -392,7 +380,7 @@ const handleVerify = async () => { ...@@ -392,7 +380,7 @@ const handleVerify = async () => {
appStore.showSuccess(t('profile.totp.enableSuccess')) appStore.showSuccess(t('profile.totp.enableSuccess'))
emit('success') emit('success')
} catch (err: any) { } 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 = ['', '', '', '', '', ''] code.value = ['', '', '', '', '', '']
nextTick(() => { nextTick(() => {
inputRefs.value[0]?.focus() inputRefs.value[0]?.focus()
......
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(() => ({ ...@@ -7,7 +7,10 @@ const mocks = vi.hoisted(() => ({
showSuccess: vi.fn(), showSuccess: vi.fn(),
showError: vi.fn(), showError: vi.fn(),
getVerificationMethod: vi.fn(), getVerificationMethod: vi.fn(),
sendVerifyCode: vi.fn() sendVerifyCode: vi.fn(),
initiateSetup: vi.fn(),
enable: vi.fn(),
disable: vi.fn()
})) }))
vi.mock('vue-i18n', () => ({ vi.mock('vue-i18n', () => ({
...@@ -27,9 +30,9 @@ vi.mock('@/api', () => ({ ...@@ -27,9 +30,9 @@ vi.mock('@/api', () => ({
totpAPI: { totpAPI: {
getVerificationMethod: mocks.getVerificationMethod, getVerificationMethod: mocks.getVerificationMethod,
sendVerifyCode: mocks.sendVerifyCode, sendVerifyCode: mocks.sendVerifyCode,
initiateSetup: vi.fn(), initiateSetup: mocks.initiateSetup,
enable: vi.fn(), enable: mocks.enable,
disable: vi.fn() disable: mocks.disable
} }
})) }))
...@@ -49,9 +52,19 @@ describe('TOTP 弹窗定时器清理', () => { ...@@ -49,9 +52,19 @@ describe('TOTP 弹窗定时器清理', () => {
mocks.showError.mockReset() mocks.showError.mockReset()
mocks.getVerificationMethod.mockReset() mocks.getVerificationMethod.mockReset()
mocks.sendVerifyCode.mockReset() mocks.sendVerifyCode.mockReset()
mocks.initiateSetup.mockReset()
mocks.enable.mockReset()
mocks.disable.mockReset()
mocks.getVerificationMethod.mockResolvedValue({ method: 'email' }) mocks.getVerificationMethod.mockResolvedValue({ method: 'email' })
mocks.sendVerifyCode.mockResolvedValue({ success: true }) 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) => { setIntervalSpy = vi.spyOn(window, 'setInterval').mockImplementation(((handler: TimerHandler) => {
void handler void handler
...@@ -105,4 +118,40 @@ describe('TOTP 弹窗定时器清理', () => { ...@@ -105,4 +118,40 @@ describe('TOTP 弹窗定时器清理', () => {
expect(clearIntervalSpy).toHaveBeenCalledWith(timerId) 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)
})
}) })
This diff is collapsed.
...@@ -700,7 +700,7 @@ const getAttributeValue = (userId: number, attrId: number): string => { ...@@ -700,7 +700,7 @@ const getAttributeValue = (userId: number, attrId: number): string => {
// All possible columns (for column settings) // All possible columns (for column settings)
const allColumns = computed<Column[]>(() => [ const allColumns = computed<Column[]>(() => [
{ key: 'email', label: t('admin.users.columns.user'), sortable: true }, { key: 'email', label: t('admin.users.columns.user'), sortable: true },
{ key: 'id', label: 'ID', sortable: true }, { key: 'id', label: t('admin.users.columns.id'), sortable: true },
{ key: 'username', label: t('admin.users.columns.username'), sortable: true }, { key: 'username', label: t('admin.users.columns.username'), sortable: true },
{ key: 'notes', label: t('admin.users.columns.notes'), sortable: false }, { key: 'notes', label: t('admin.users.columns.notes'), sortable: false },
// Dynamic attribute columns // Dynamic attribute columns
......
...@@ -93,10 +93,61 @@ vi.mock("@/utils/apiError", () => ({ ...@@ -93,10 +93,61 @@ vi.mock("@/utils/apiError", () => ({
vi.mock("vue-i18n", async () => { vi.mock("vue-i18n", async () => {
const actual = await vi.importActual<typeof import("vue-i18n")>("vue-i18n"); const actual = await vi.importActual<typeof import("vue-i18n")>("vue-i18n");
const translations: Record<string, string> = {
"admin.settings.wechatConnect.title": "微信登录",
"admin.settings.wechatConnect.description": "用于微信开放平台或公众号/小程序的第三方登录配置。",
"admin.settings.wechatConnect.enabledLabel": "启用微信登录",
"admin.settings.wechatConnect.enabledHint": "开启后可使用微信第三方登录回调与授权配置。",
"admin.settings.wechatConnect.appIdLabel": "AppID",
"admin.settings.wechatConnect.appIdPlaceholder": "微信开放平台 AppID",
"admin.settings.wechatConnect.appSecretLabel": "AppSecret",
"admin.settings.wechatConnect.appSecretConfiguredPlaceholder": "密钥已配置,留空以保留当前值。",
"admin.settings.wechatConnect.appSecretPlaceholder": "微信开放平台 AppSecret",
"admin.settings.wechatConnect.appSecretConfiguredHint": "密钥已配置,留空以保留当前值。",
"admin.settings.wechatConnect.appSecretHint": "填写后会覆盖当前微信密钥。",
"admin.settings.wechatConnect.modeLabel": "模式",
"admin.settings.wechatConnect.openModeLabel": "非微信环境使用开放平台",
"admin.settings.wechatConnect.openModeHint": "浏览器不在微信内时,自动走开放平台扫码授权。",
"admin.settings.wechatConnect.mpModeLabel": "微信环境使用公众号",
"admin.settings.wechatConnect.mpModeHint": "浏览器在微信内时,自动走公众号授权。",
"admin.settings.wechatConnect.redirectUrlLabel": "回调地址",
"admin.settings.wechatConnect.redirectUrlPlaceholder": "https://your-site.com/api/v1/auth/oauth/wechat/callback",
"admin.settings.wechatConnect.generateAndCopy": "使用当前站点生成并复制",
"admin.settings.wechatConnect.redirectUrlSetAndCopied": "已使用当前站点生成回调地址并复制到剪贴板",
"admin.settings.wechatConnect.frontendRedirectUrlLabel": "前端回调地址",
"admin.settings.wechatConnect.frontendRedirectUrlPlaceholder": "/auth/wechat/callback",
"admin.settings.wechatConnect.frontendRedirectUrlHint": "通常用于前端路由回调地址,需与后端配置保持一致。",
"admin.settings.authSourceDefaults.title": "认证来源默认值",
"admin.settings.authSourceDefaults.description": "按注册来源配置新用户默认余额、并发、订阅与授权策略。",
"admin.settings.authSourceDefaults.requireEmailLabel": "第三方注册强制补充邮箱",
"admin.settings.authSourceDefaults.requireEmailHint": "启用后,Linux DO、OIDC、微信注册缺少邮箱时必须先补充邮箱地址。",
"admin.settings.authSourceDefaults.enabledHint": "以下默认值会在该来源注册新用户时发放;首次绑定时授权仅作用于已有账号绑定该来源。",
"admin.settings.authSourceDefaults.sources.email.title": "邮箱注册",
"admin.settings.authSourceDefaults.sources.email.description": "适用于邮箱密码注册的新用户默认配额。",
"admin.settings.authSourceDefaults.sources.linuxdo.title": "Linux DO 登录",
"admin.settings.authSourceDefaults.sources.linuxdo.description": "适用于 Linux DO 第三方注册的新用户默认配额。",
"admin.settings.authSourceDefaults.sources.oidc.title": "OIDC 登录",
"admin.settings.authSourceDefaults.sources.oidc.description": "适用于 OIDC 第三方注册的新用户默认配额。",
"admin.settings.authSourceDefaults.sources.wechat.title": "微信登录",
"admin.settings.authSourceDefaults.sources.wechat.description": "适用于微信第三方注册的新用户默认配额。",
"admin.settings.authSourceDefaults.grantOnFirstBindLabel": "首次绑定时授权",
"admin.settings.authSourceDefaults.grantOnFirstBindHint": "已有账号首次绑定该来源时发放默认权益。",
"admin.settings.authSourceDefaults.defaultSubscriptionsLabel": "默认订阅",
"admin.settings.authSourceDefaults.defaultSubscriptionsHint": "仅对当前认证来源生效,未配置时不追加来源专属订阅。",
"admin.settings.authSourceDefaults.noSourceSubscriptions": "当前来源未配置专属默认订阅。",
"admin.settings.paymentVisibleMethods.methodLabel": "{title} 可见方式",
"admin.settings.paymentVisibleMethods.methodHint": "控制前台结算页是否展示该方式,以及展示时使用的来源键。",
"admin.settings.paymentVisibleMethods.sourceLabel": "支付来源",
"admin.settings.paymentVisibleMethods.sourceHint": "启用后必须明确选择一个来源;未配置状态不会对外展示该支付方式。",
"admin.settings.paymentVisibleMethods.sourceRequiredError": "{title} 已启用,请先选择支付来源。",
"admin.settings.openaiExperimentalScheduler.title": "OpenAI 实验调度策略",
"admin.settings.openaiExperimentalScheduler.description": "默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。",
};
return { return {
...actual, ...actual,
useI18n: () => ({ useI18n: () => ({
t: (key: string) => key, t: (key: string, params?: Record<string, string>) =>
(translations[key] ?? key).replace(/\{(\w+)\}/g, (_, token) => params?.[token] ?? `{${token}}`),
locale: ref("zh-CN"), locale: ref("zh-CN"),
}), }),
}; };
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment