frontend: normalize auth oauth i18n and error toasts
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
:data-testid="`${testIdPrefix}-create-account-email`"
|
||||
type="email"
|
||||
class="input w-full"
|
||||
placeholder="you@example.com"
|
||||
:placeholder="t('auth.emailPlaceholder')"
|
||||
:disabled="isSubmitting || isSendingCode"
|
||||
/>
|
||||
<input
|
||||
@@ -13,7 +13,7 @@
|
||||
:data-testid="`${testIdPrefix}-create-account-password`"
|
||||
type="password"
|
||||
class="input w-full"
|
||||
placeholder="Password"
|
||||
:placeholder="t('auth.passwordPlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
/>
|
||||
<div v-if="turnstileEnabled && turnstileSiteKey" class="space-y-2">
|
||||
@@ -26,16 +26,16 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<input
|
||||
v-model="verifyCode"
|
||||
:data-testid="`${testIdPrefix}-create-account-verify-code`"
|
||||
type="text"
|
||||
<input
|
||||
v-model="verifyCode"
|
||||
:data-testid="`${testIdPrefix}-create-account-verify-code`"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
class="input min-w-0 flex-1"
|
||||
placeholder="123456"
|
||||
:disabled="isSubmitting"
|
||||
/>
|
||||
maxlength="6"
|
||||
class="input min-w-0 flex-1"
|
||||
placeholder="123456"
|
||||
:disabled="isSubmitting"
|
||||
/>
|
||||
<button
|
||||
:data-testid="`${testIdPrefix}-create-account-send-code`"
|
||||
type="button"
|
||||
@@ -74,7 +74,7 @@
|
||||
:disabled="isSubmitting || !email.trim() || password.length < 6 || (invitationCodeEnabled && !invitationCode.trim())"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ isSubmitting ? t('common.processing') : 'Create account' }}
|
||||
{{ isSubmitting ? t('common.processing') : t('auth.createAccount') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -82,18 +82,8 @@
|
||||
:disabled="isSubmitting"
|
||||
@click="emitSwitchToBind"
|
||||
>
|
||||
I already have an account
|
||||
{{ t('auth.alreadyHaveAccount') }}
|
||||
</button>
|
||||
<transition name="fade">
|
||||
<p v-if="sendCodeError" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ sendCodeError }}
|
||||
</p>
|
||||
</transition>
|
||||
<transition name="fade">
|
||||
<p v-if="errorMessage" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</transition>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
@@ -102,6 +92,7 @@ import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import { getPublicSettings, sendPendingOAuthVerifyCode } from '@/api/auth'
|
||||
import { useAppStore } from '@/stores'
|
||||
|
||||
export type PendingOAuthCreateAccountPayload = {
|
||||
email: string
|
||||
@@ -123,6 +114,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
@@ -148,6 +140,21 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(sendCodeError, value => {
|
||||
if (value) {
|
||||
appStore.showError(value)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.errorMessage,
|
||||
value => {
|
||||
if (value) {
|
||||
appStore.showError(value)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function clearCountdown() {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
|
||||
@@ -47,11 +47,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<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>
|
||||
|
||||
<!-- Cancel button only -->
|
||||
<button
|
||||
type="button"
|
||||
@@ -69,6 +64,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores'
|
||||
|
||||
defineProps<{
|
||||
tempToken: string
|
||||
@@ -81,9 +77,9 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const verifying = ref(false)
|
||||
const error = ref('')
|
||||
const code = ref<string[]>(['', '', '', '', '', ''])
|
||||
const inputRefs = ref<(HTMLInputElement | null)[]>([])
|
||||
|
||||
@@ -100,7 +96,9 @@ watch(
|
||||
defineExpose({
|
||||
setVerifying: (value: boolean) => { verifying.value = value },
|
||||
setError: (message: string) => {
|
||||
error.value = message
|
||||
if (message) {
|
||||
appStore.showError(message)
|
||||
}
|
||||
code.value = ['', '', '', '', '', '']
|
||||
// Clear input DOM values
|
||||
inputRefs.value.forEach(input => {
|
||||
|
||||
@@ -43,8 +43,8 @@ const props = withDefaults(defineProps<{
|
||||
|
||||
const appStore = useAppStore()
|
||||
const route = useRoute()
|
||||
const { locale, t } = useI18n()
|
||||
const providerName = 'WeChat'
|
||||
const { t } = useI18n()
|
||||
const providerName = computed(() => t('auth.wechatProviderName'))
|
||||
|
||||
const resolvedStart = computed(() => resolveWeChatOAuthStart(appStore.cachedPublicSettings))
|
||||
const buttonDisabled = computed(() => props.disabled || resolvedStart.value.mode === null)
|
||||
@@ -54,29 +54,16 @@ const disabledHint = computed(() => {
|
||||
}
|
||||
switch (resolvedStart.value.unavailableReason) {
|
||||
case 'external_browser_required':
|
||||
return localizeWeChatHint(
|
||||
'当前仅配置网站微信登录,请在系统浏览器中打开此页面后再继续。',
|
||||
'This site only has WeChat website login configured. Open this page in your browser to continue.',
|
||||
)
|
||||
return t('auth.oauthFlow.wechatSystemBrowserOnly')
|
||||
case 'wechat_browser_required':
|
||||
return localizeWeChatHint(
|
||||
'当前仅配置微信内登录,请在微信中打开此页面后再继续。',
|
||||
'This site only has WeChat in-app login configured. Open this page inside WeChat to continue.',
|
||||
)
|
||||
return t('auth.oauthFlow.wechatBrowserOnly')
|
||||
case 'not_configured':
|
||||
return localizeWeChatHint(
|
||||
'管理员尚未配置微信登录。',
|
||||
'WeChat sign-in is not configured yet.',
|
||||
)
|
||||
return t('auth.oauthFlow.wechatNotConfigured')
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
function localizeWeChatHint(zh: string, en: string): string {
|
||||
return locale.value.toLowerCase().startsWith('zh') ? zh : en
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!appStore.cachedPublicSettings && !appStore.publicSettingsLoaded) {
|
||||
appStore.fetchPublicSettings()
|
||||
|
||||
@@ -6,6 +6,7 @@ import PendingOAuthCreateAccountForm from '../PendingOAuthCreateAccountForm.vue'
|
||||
const sendVerifyCode = vi.fn()
|
||||
const sendPendingOAuthVerifyCode = vi.fn()
|
||||
const getPublicSettings = vi.fn()
|
||||
const showError = vi.fn()
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
@@ -27,11 +28,18 @@ vi.mock('@/api/auth', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useAppStore: () => ({
|
||||
showError
|
||||
})
|
||||
}))
|
||||
|
||||
describe('PendingOAuthCreateAccountForm', () => {
|
||||
beforeEach(() => {
|
||||
sendVerifyCode.mockReset()
|
||||
sendPendingOAuthVerifyCode.mockReset()
|
||||
getPublicSettings.mockReset()
|
||||
showError.mockReset()
|
||||
getPublicSettings.mockResolvedValue({
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: ''
|
||||
@@ -64,6 +72,19 @@ describe('PendingOAuthCreateAccountForm', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('renders action labels through i18n keys', () => {
|
||||
const wrapper = mount(PendingOAuthCreateAccountForm, {
|
||||
props: {
|
||||
testIdPrefix: 'linuxdo',
|
||||
initialEmail: '',
|
||||
isSubmitting: false
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('auth.createAccount')
|
||||
expect(wrapper.text()).toContain('auth.alreadyHaveAccount')
|
||||
})
|
||||
|
||||
it('shows and emits invitation code when invitation-only signup is enabled', async () => {
|
||||
getPublicSettings.mockResolvedValue({
|
||||
invitation_code_enabled: true,
|
||||
@@ -122,6 +143,25 @@ describe('PendingOAuthCreateAccountForm', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows send-code failures via toast without rendering inline error text', async () => {
|
||||
sendPendingOAuthVerifyCode.mockRejectedValue(new Error('send failed'))
|
||||
|
||||
const wrapper = mount(PendingOAuthCreateAccountForm, {
|
||||
props: {
|
||||
testIdPrefix: 'linuxdo',
|
||||
initialEmail: '',
|
||||
isSubmitting: false
|
||||
}
|
||||
})
|
||||
|
||||
await wrapper.get('[data-testid="linuxdo-create-account-email"]').setValue('user@example.com')
|
||||
await wrapper.get('[data-testid="linuxdo-create-account-send-code"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(showError).toHaveBeenCalledWith('send failed')
|
||||
expect(wrapper.text()).not.toContain('send failed')
|
||||
})
|
||||
|
||||
it('requires a turnstile token before sending a verify code when turnstile is enabled', async () => {
|
||||
getPublicSettings.mockResolvedValue({
|
||||
turnstile_enabled: true,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import TotpLoginModal from '@/components/auth/TotpLoginModal.vue'
|
||||
|
||||
const { showErrorMock } = vi.hoisted(() => ({
|
||||
showErrorMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useAppStore: () => ({
|
||||
showError: (...args: any[]) => showErrorMock(...args),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('TotpLoginModal', () => {
|
||||
beforeEach(() => {
|
||||
showErrorMock.mockReset()
|
||||
})
|
||||
|
||||
it('sends verification errors to toast and does not render inline red text', async () => {
|
||||
const wrapper = mount(TotpLoginModal, {
|
||||
props: {
|
||||
tempToken: 'temp-token',
|
||||
userEmailMasked: 'u***@example.com',
|
||||
},
|
||||
})
|
||||
|
||||
;(wrapper.vm as unknown as { setError: (message: string) => void }).setError('Invalid code')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(showErrorMock).toHaveBeenCalledWith('Invalid code')
|
||||
expect(wrapper.text()).not.toContain('Invalid code')
|
||||
expect(wrapper.find('.bg-red-50').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -26,9 +26,21 @@ vi.mock('vue-i18n', async () => {
|
||||
useI18n: () => ({
|
||||
locale: { value: 'en' },
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
if (key === 'auth.wechatProviderName') {
|
||||
return 'Mock WeChat'
|
||||
}
|
||||
if (key === 'auth.oidc.signIn') {
|
||||
return `Continue with ${params?.providerName ?? ''}`.trim()
|
||||
}
|
||||
if (key === 'auth.oauthFlow.wechatSystemBrowserOnly') {
|
||||
return 'MOCK-SYSTEM-BROWSER-ONLY'
|
||||
}
|
||||
if (key === 'auth.oauthFlow.wechatBrowserOnly') {
|
||||
return 'MOCK-WECHAT-BROWSER-ONLY'
|
||||
}
|
||||
if (key === 'auth.oauthFlow.wechatNotConfigured') {
|
||||
return 'MOCK-NOT-CONFIGURED'
|
||||
}
|
||||
if (key === 'auth.oauthOrContinue') {
|
||||
return 'or continue'
|
||||
}
|
||||
@@ -118,7 +130,7 @@ describe('WechatOAuthSection', () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('WeChat')
|
||||
expect(wrapper.text()).toContain('Mock WeChat')
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
@@ -161,7 +173,7 @@ describe('WechatOAuthSection', () => {
|
||||
})
|
||||
|
||||
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).toContain('Open this page inside WeChat to continue.')
|
||||
expect(wrapper.text()).toContain('MOCK-WECHAT-BROWSER-ONLY')
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
@@ -184,7 +196,7 @@ describe('WechatOAuthSection', () => {
|
||||
})
|
||||
|
||||
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).toContain('This site only has WeChat website login configured. Open this page in your browser to continue.')
|
||||
expect(wrapper.text()).toContain('MOCK-SYSTEM-BROWSER-ONLY')
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
@@ -207,4 +219,20 @@ describe('WechatOAuthSection', () => {
|
||||
'/api/v1/auth/oauth/wechat/start?mode=open&redirect=%2Fbilling%3Fplan%3Dpro'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the localized not-configured hint when WeChat OAuth is unavailable', async () => {
|
||||
seedPublicSettings({
|
||||
wechat_oauth_enabled: false,
|
||||
wechat_oauth_open_enabled: false,
|
||||
wechat_oauth_mp_enabled: false,
|
||||
})
|
||||
|
||||
const wrapper = mount(WechatOAuthSection, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('MOCK-NOT-CONFIGURED')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user