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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -309,6 +309,7 @@ export default {
|
||||
view: 'View',
|
||||
settings: 'Settings',
|
||||
chooseFile: 'Choose File',
|
||||
copy: 'Copy',
|
||||
notAvailable: 'N/A',
|
||||
now: 'Now',
|
||||
today: 'Today',
|
||||
@@ -407,6 +408,7 @@ export default {
|
||||
verificationCode: 'Verification Code',
|
||||
verificationCodeHint: 'Enter the 6-digit code sent to your email',
|
||||
sendingCode: 'Sending...',
|
||||
sendCode: 'Send code',
|
||||
clickToResend: 'Click to resend code',
|
||||
resendCode: 'Resend verification code',
|
||||
sendCodeDesc: "We'll send a verification code to",
|
||||
@@ -466,7 +468,51 @@ export default {
|
||||
completing: 'Completing registration…',
|
||||
completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.'
|
||||
},
|
||||
oauthFlow: {
|
||||
profileDetailsTitle: 'Use {providerName} profile details',
|
||||
profileDetailsDescription: 'Choose whether to apply the nickname or avatar from {providerName} to this account.',
|
||||
useDisplayName: 'Use display name',
|
||||
useAvatar: 'Use avatar',
|
||||
avatarAlt: '{providerName} avatar',
|
||||
reviewProfileBeforeContinue: 'Review the {providerName} profile details before continuing.',
|
||||
chooseHowToContinue: 'Choose how to continue',
|
||||
chooseAccountActionHint: 'Choose whether to bind an existing account or create a new one.',
|
||||
suggestedEmail: 'Suggested email: {email}',
|
||||
bindExistingAccount: 'Bind existing account',
|
||||
createNewAccount: 'Create new account',
|
||||
createAccountHint: 'Enter an email address to create your account and continue.',
|
||||
bindLoginHint: 'Log in to an existing account to bind this {providerName} sign-in.',
|
||||
signInThenBindDescription: 'Sign in to an existing account, then bind this {providerName} sign-in to it.',
|
||||
bindSignInToExistingAccount: 'Bind this {providerName} sign-in to an existing account.',
|
||||
bindCurrentAccountTitle: 'Bind the current account',
|
||||
bindCurrentAccountDescription: 'Bind this {providerName} sign-in to the account currently signed in on this browser.',
|
||||
bindCurrentAccount: 'Bind current account',
|
||||
logInAndBind: 'Log in and bind',
|
||||
useDifferentEmail: 'Use a different email',
|
||||
backToOptions: 'Back to options',
|
||||
yourAccount: 'your account',
|
||||
totpHint: 'Enter the 6-digit verification code for {account} to finish binding this {providerName} sign-in.',
|
||||
verifyAndContinue: 'Verify and continue',
|
||||
wechatAvailabilityUnknown: 'WeChat sign-in availability could not be confirmed. Refresh and retry.',
|
||||
wechatSystemBrowserOnly: 'This WeChat sign-in flow is only available in your system browser.',
|
||||
wechatBrowserOnly: 'This WeChat sign-in flow is only available inside the WeChat browser.',
|
||||
wechatNotConfigured: 'WeChat sign-in is not configured yet.'
|
||||
},
|
||||
linuxdoCallbackPageTitle: 'LinuxDo Sign-In Callback',
|
||||
oidcCallbackPageTitle: 'OIDC Sign-In Callback',
|
||||
oauthCallbackPageTitle: 'OAuth Callback',
|
||||
wechatProviderName: 'WeChat',
|
||||
wechatCallbackPageTitle: 'WeChat Sign-In Callback',
|
||||
wechatPaymentCallbackPageTitle: 'WeChat Payment Callback',
|
||||
wechatPayment: {
|
||||
callbackTitle: 'Resuming WeChat payment',
|
||||
callbackProcessing: 'Resuming WeChat payment...',
|
||||
backToPayment: 'Back to payment',
|
||||
callbackMissingResumeToken: 'The WeChat payment callback is missing the resume token.'
|
||||
},
|
||||
oauth: {
|
||||
callbackTitle: 'OAuth Callback',
|
||||
callbackHint: 'Copy the code and state back to the admin authorization flow when needed.',
|
||||
code: 'Code',
|
||||
state: 'State',
|
||||
fullUrl: 'Full URL'
|
||||
@@ -1425,6 +1471,7 @@ export default {
|
||||
updating: 'Updating...',
|
||||
columns: {
|
||||
user: 'User',
|
||||
id: 'ID',
|
||||
email: 'Email',
|
||||
username: 'Username',
|
||||
notes: 'Notes',
|
||||
@@ -4972,6 +5019,72 @@ export default {
|
||||
presetOpusOnlyDesc: 'Pass for Opus, filter others',
|
||||
commonPatterns: 'Common patterns'
|
||||
},
|
||||
wechatConnect: {
|
||||
title: 'WeChat Connect',
|
||||
description: 'Third-party login configuration for WeChat Open Platform or Official Account / Mini Program.',
|
||||
enabledLabel: 'Enable WeChat Connect',
|
||||
enabledHint: 'Enable this to configure WeChat OAuth callbacks and authorization.',
|
||||
appIdLabel: 'App ID',
|
||||
appIdPlaceholder: 'WeChat App ID',
|
||||
appSecretLabel: 'App Secret',
|
||||
appSecretConfiguredPlaceholder: 'Secret configured. Leave empty to keep the current value.',
|
||||
appSecretPlaceholder: 'WeChat App Secret',
|
||||
appSecretConfiguredHint: 'Secret configured. Leave empty to keep the current value.',
|
||||
appSecretHint: 'Enter a new secret to replace the current WeChat credential.',
|
||||
modeLabel: 'Mode',
|
||||
openModeLabel: 'Use Open outside WeChat',
|
||||
openModeHint: 'Use Open Platform QR authorization outside the WeChat browser.',
|
||||
mpModeLabel: 'Use MP inside WeChat',
|
||||
mpModeHint: 'Use Official Account authorization inside the WeChat browser.',
|
||||
redirectUrlLabel: 'Redirect URL',
|
||||
redirectUrlPlaceholder: 'https://your-site.com/api/v1/auth/oauth/wechat/callback',
|
||||
generateAndCopy: 'Generate & Copy (current site)',
|
||||
redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard',
|
||||
frontendRedirectUrlLabel: 'Frontend redirect URL',
|
||||
frontendRedirectUrlPlaceholder: '/auth/wechat/callback',
|
||||
frontendRedirectUrlHint: 'Usually the frontend route callback path; keep it aligned with the backend.'
|
||||
},
|
||||
authSourceDefaults: {
|
||||
title: 'Auth Source Defaults',
|
||||
description: 'Configure per-source default balance, concurrency, subscriptions, and grant rules.',
|
||||
requireEmailLabel: 'Require email on third-party signup',
|
||||
requireEmailHint: 'When enabled, Linux DO, OIDC, and WeChat signups must provide an email before account creation.',
|
||||
enabledHint: 'These defaults apply when a new user registers through this source. Grant on first bind only applies when an existing user binds this source.',
|
||||
sources: {
|
||||
email: {
|
||||
title: 'Email signup',
|
||||
description: 'Default quota grants for email-password signups.'
|
||||
},
|
||||
linuxdo: {
|
||||
title: 'Linux DO signup',
|
||||
description: 'Default quota grants for Linux DO signups.'
|
||||
},
|
||||
oidc: {
|
||||
title: 'OIDC signup',
|
||||
description: 'Default quota grants for OIDC signups.'
|
||||
},
|
||||
wechat: {
|
||||
title: 'WeChat signup',
|
||||
description: 'Default quota grants for WeChat signups.'
|
||||
}
|
||||
},
|
||||
grantOnFirstBindLabel: 'Grant on first bind',
|
||||
grantOnFirstBindHint: 'Grant default entitlements when an existing user first binds this source.',
|
||||
defaultSubscriptionsLabel: 'Default subscriptions',
|
||||
defaultSubscriptionsHint: 'Applies only to this auth source. Leave empty to skip source-specific subscriptions.',
|
||||
noSourceSubscriptions: 'No source-specific default subscriptions configured.'
|
||||
},
|
||||
paymentVisibleMethods: {
|
||||
methodLabel: '{title} visible method',
|
||||
methodHint: 'Controls whether checkout shows this method and which source key it exposes.',
|
||||
sourceLabel: 'Payment source',
|
||||
sourceHint: 'Choose an explicit source before enabling the method. Not configured methods are not exposed.',
|
||||
sourceRequiredError: 'Select a payment source before enabling {title}.'
|
||||
},
|
||||
openaiExperimentalScheduler: {
|
||||
title: 'OpenAI experimental scheduler policy',
|
||||
description: "Disabled by default. When enabled, this only changes the gateway's experimental account-selection policy for OpenAI traffic; it does not indicate an upstream OpenAI capability."
|
||||
},
|
||||
saveSettings: 'Save Settings',
|
||||
saving: 'Saving...',
|
||||
settingsSaved: 'Settings saved successfully',
|
||||
@@ -5461,6 +5574,7 @@ export default {
|
||||
viewOrders: 'View Orders',
|
||||
},
|
||||
currentBalance: 'Current Balance',
|
||||
groupFallback: 'Group #{id}',
|
||||
rechargeAccount: 'Recharge Account',
|
||||
activeSubscription: 'Active Subscription',
|
||||
noActiveSubscription: 'No active subscription',
|
||||
|
||||
@@ -309,6 +309,7 @@ export default {
|
||||
view: '查看',
|
||||
settings: '设置',
|
||||
chooseFile: '选择文件',
|
||||
copy: '复制',
|
||||
notAvailable: '不可用',
|
||||
now: '现在',
|
||||
today: '今天',
|
||||
@@ -406,6 +407,7 @@ export default {
|
||||
verificationCode: '验证码',
|
||||
verificationCodeHint: '请输入发送到您邮箱的6位验证码',
|
||||
sendingCode: '发送中...',
|
||||
sendCode: '发送验证码',
|
||||
clickToResend: '点击重新发送验证码',
|
||||
resendCode: '重新发送验证码',
|
||||
sendCodeDesc: '我们将发送验证码到',
|
||||
@@ -464,7 +466,51 @@ export default {
|
||||
completing: '正在完成注册...',
|
||||
completeRegistrationFailed: '注册失败,请检查邀请码后重试。'
|
||||
},
|
||||
oauthFlow: {
|
||||
profileDetailsTitle: '使用 {providerName} 资料',
|
||||
profileDetailsDescription: '选择是否将 {providerName} 的昵称或头像应用到当前账户。',
|
||||
useDisplayName: '使用昵称',
|
||||
useAvatar: '使用头像',
|
||||
avatarAlt: '{providerName} 头像',
|
||||
reviewProfileBeforeContinue: '请先确认 {providerName} 资料后再继续。',
|
||||
chooseHowToContinue: '选择后续操作',
|
||||
chooseAccountActionHint: '请选择绑定已有账户,或创建一个新账户。',
|
||||
suggestedEmail: '建议邮箱:{email}',
|
||||
bindExistingAccount: '绑定已有账户',
|
||||
createNewAccount: '创建新账户',
|
||||
createAccountHint: '请输入邮箱地址以创建账户并继续。',
|
||||
bindLoginHint: '登录一个已有账户以绑定此次 {providerName} 登录。',
|
||||
signInThenBindDescription: '请先登录已有账户,再将此次 {providerName} 登录绑定到该账户。',
|
||||
bindSignInToExistingAccount: '将此次 {providerName} 登录绑定到已有账户。',
|
||||
bindCurrentAccountTitle: '绑定当前账户',
|
||||
bindCurrentAccountDescription: '将此次 {providerName} 登录绑定到当前浏览器已登录的账户。',
|
||||
bindCurrentAccount: '绑定当前账户',
|
||||
logInAndBind: '登录并绑定',
|
||||
useDifferentEmail: '使用其他邮箱',
|
||||
backToOptions: '返回选项',
|
||||
yourAccount: '当前账户',
|
||||
totpHint: '请输入 {account} 的 6 位验证码,以完成此次 {providerName} 登录绑定。',
|
||||
verifyAndContinue: '验证并继续',
|
||||
wechatAvailabilityUnknown: '暂时无法确认微信登录可用性,请刷新后重试。',
|
||||
wechatSystemBrowserOnly: '当前微信登录流程仅支持在系统浏览器中继续。',
|
||||
wechatBrowserOnly: '当前微信登录流程仅支持在微信内置浏览器中继续。',
|
||||
wechatNotConfigured: '微信登录尚未配置。'
|
||||
},
|
||||
linuxdoCallbackPageTitle: 'LinuxDo 登录回调',
|
||||
oidcCallbackPageTitle: 'OIDC 登录回调',
|
||||
oauthCallbackPageTitle: 'OAuth 回调',
|
||||
wechatProviderName: '微信',
|
||||
wechatCallbackPageTitle: '微信登录回调',
|
||||
wechatPaymentCallbackPageTitle: '微信支付回调',
|
||||
wechatPayment: {
|
||||
callbackTitle: '正在恢复微信支付',
|
||||
callbackProcessing: '正在恢复微信支付...',
|
||||
backToPayment: '返回支付页',
|
||||
callbackMissingResumeToken: '微信支付回调缺少恢复令牌。'
|
||||
},
|
||||
oauth: {
|
||||
callbackTitle: 'OAuth 回调',
|
||||
callbackHint: '按需将授权码和状态值复制回后台授权流程。',
|
||||
code: '授权码',
|
||||
state: '状态',
|
||||
fullUrl: '完整URL'
|
||||
@@ -1451,6 +1497,7 @@ export default {
|
||||
updating: '更新中...',
|
||||
columns: {
|
||||
user: '用户',
|
||||
id: 'ID',
|
||||
email: '邮箱',
|
||||
username: '用户名',
|
||||
notes: '备注',
|
||||
@@ -5135,6 +5182,72 @@ export default {
|
||||
presetOpusOnlyDesc: 'Opus 透传,其他模型过滤',
|
||||
commonPatterns: '常用模式'
|
||||
},
|
||||
wechatConnect: {
|
||||
title: '微信登录',
|
||||
description: '用于微信开放平台或公众号/小程序的第三方登录配置。',
|
||||
enabledLabel: '启用微信登录',
|
||||
enabledHint: '开启后可使用微信第三方登录回调与授权配置。',
|
||||
appIdLabel: 'AppID',
|
||||
appIdPlaceholder: '微信开放平台 AppID',
|
||||
appSecretLabel: 'AppSecret',
|
||||
appSecretConfiguredPlaceholder: '密钥已配置,留空以保留当前值。',
|
||||
appSecretPlaceholder: '微信开放平台 AppSecret',
|
||||
appSecretConfiguredHint: '密钥已配置,留空以保留当前值。',
|
||||
appSecretHint: '填写后会覆盖当前微信密钥。',
|
||||
modeLabel: '模式',
|
||||
openModeLabel: '非微信环境使用开放平台',
|
||||
openModeHint: '浏览器不在微信内时,自动走开放平台扫码授权。',
|
||||
mpModeLabel: '微信环境使用公众号',
|
||||
mpModeHint: '浏览器在微信内时,自动走公众号授权。',
|
||||
redirectUrlLabel: '回调地址',
|
||||
redirectUrlPlaceholder: 'https://your-site.com/api/v1/auth/oauth/wechat/callback',
|
||||
generateAndCopy: '使用当前站点生成并复制',
|
||||
redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板',
|
||||
frontendRedirectUrlLabel: '前端回调地址',
|
||||
frontendRedirectUrlPlaceholder: '/auth/wechat/callback',
|
||||
frontendRedirectUrlHint: '通常用于前端路由回调地址,需与后端配置保持一致。'
|
||||
},
|
||||
authSourceDefaults: {
|
||||
title: '认证来源默认值',
|
||||
description: '按注册来源配置新用户默认余额、并发、订阅与授权策略。',
|
||||
requireEmailLabel: '第三方注册强制补充邮箱',
|
||||
requireEmailHint: '启用后,Linux DO、OIDC、微信注册缺少邮箱时必须先补充邮箱地址。',
|
||||
enabledHint: '以下默认值会在该来源注册新用户时发放;首次绑定时授权仅作用于已有账号绑定该来源。',
|
||||
sources: {
|
||||
email: {
|
||||
title: '邮箱注册',
|
||||
description: '适用于邮箱密码注册的新用户默认配额。'
|
||||
},
|
||||
linuxdo: {
|
||||
title: 'Linux DO 登录',
|
||||
description: '适用于 Linux DO 第三方注册的新用户默认配额。'
|
||||
},
|
||||
oidc: {
|
||||
title: 'OIDC 登录',
|
||||
description: '适用于 OIDC 第三方注册的新用户默认配额。'
|
||||
},
|
||||
wechat: {
|
||||
title: '微信登录',
|
||||
description: '适用于微信第三方注册的新用户默认配额。'
|
||||
}
|
||||
},
|
||||
grantOnFirstBindLabel: '首次绑定时授权',
|
||||
grantOnFirstBindHint: '已有账号首次绑定该来源时发放默认权益。',
|
||||
defaultSubscriptionsLabel: '默认订阅',
|
||||
defaultSubscriptionsHint: '仅对当前认证来源生效,未配置时不追加来源专属订阅。',
|
||||
noSourceSubscriptions: '当前来源未配置专属默认订阅。'
|
||||
},
|
||||
paymentVisibleMethods: {
|
||||
methodLabel: '{title} 可见方式',
|
||||
methodHint: '控制前台结算页是否展示该方式,以及展示时使用的来源键。',
|
||||
sourceLabel: '支付来源',
|
||||
sourceHint: '启用后必须明确选择一个来源;未配置状态不会对外展示该支付方式。',
|
||||
sourceRequiredError: '{title} 已启用,请先选择支付来源。'
|
||||
},
|
||||
openaiExperimentalScheduler: {
|
||||
title: 'OpenAI 实验调度策略',
|
||||
description: '默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。'
|
||||
},
|
||||
saveSettings: '保存设置',
|
||||
saving: '保存中...',
|
||||
settingsSaved: '设置保存成功',
|
||||
@@ -5649,6 +5762,7 @@ export default {
|
||||
viewOrders: '查看订单',
|
||||
},
|
||||
currentBalance: '当前余额',
|
||||
groupFallback: '分组 #{id}',
|
||||
rechargeAccount: '充值账户',
|
||||
activeSubscription: '当前订阅',
|
||||
noActiveSubscription: '暂无有效订阅',
|
||||
|
||||
@@ -71,7 +71,8 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/auth/OAuthCallbackView.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: 'OAuth Callback'
|
||||
title: 'OAuth Callback',
|
||||
titleKey: 'auth.oauthCallbackPageTitle'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -80,7 +81,8 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/auth/LinuxDoCallbackView.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: 'LinuxDo OAuth Callback'
|
||||
title: 'LinuxDo OAuth Callback',
|
||||
titleKey: 'auth.linuxdoCallbackPageTitle'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -89,7 +91,8 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/auth/WechatCallbackView.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: 'WeChat OAuth Callback'
|
||||
title: 'WeChat OAuth Callback',
|
||||
titleKey: 'auth.wechatCallbackPageTitle'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -98,7 +101,8 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/auth/WechatPaymentCallbackView.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: 'WeChat Payment Callback'
|
||||
title: 'WeChat Payment Callback',
|
||||
titleKey: 'auth.wechatPaymentCallbackPageTitle'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -107,7 +111,8 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/auth/OidcCallbackView.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: 'OIDC OAuth Callback'
|
||||
title: 'OIDC OAuth Callback',
|
||||
titleKey: 'auth.oidcCallbackPageTitle'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Toast, ToastType, PublicSettings } from '@/types'
|
||||
import { i18n } from '@/i18n'
|
||||
import {
|
||||
checkUpdates as checkUpdatesAPI,
|
||||
type VersionInfo,
|
||||
@@ -209,7 +210,10 @@ export const useAppStore = defineStore('app', () => {
|
||||
try {
|
||||
return await operation()
|
||||
} catch (error) {
|
||||
const message = errorMessage || (error as { message?: string }).message || 'An error occurred'
|
||||
const message =
|
||||
errorMessage ||
|
||||
(error as { message?: string }).message ||
|
||||
i18n.global.t('common.unknownError')
|
||||
showError(message)
|
||||
return null
|
||||
} finally {
|
||||
|
||||
@@ -48,10 +48,7 @@
|
||||
:class="{ 'input-error': errors.code }"
|
||||
placeholder="000000"
|
||||
/>
|
||||
<p v-if="errors.code" class="input-error-text text-center">
|
||||
{{ errors.code }}
|
||||
</p>
|
||||
<p v-else class="input-hint text-center">{{ t('auth.verificationCodeHint') }}</p>
|
||||
<p class="input-hint text-center">{{ t('auth.verificationCodeHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Code Status -->
|
||||
@@ -78,28 +75,8 @@
|
||||
@expire="onTurnstileExpire"
|
||||
@error="onTurnstileError"
|
||||
/>
|
||||
<p v-if="errors.turnstile" class="input-error-text mt-2 text-center">
|
||||
{{ errors.turnstile }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<Icon name="exclamationCircle" size="md" class="text-red-500" />
|
||||
</div>
|
||||
<p class="text-sm text-red-700 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button type="submit" :disabled="isLoading || !verifyCode" class="btn btn-primary w-full">
|
||||
<svg
|
||||
@@ -169,7 +146,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { computed, ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
@@ -258,6 +235,16 @@ const errors = ref({
|
||||
turnstile: ''
|
||||
})
|
||||
|
||||
const validationToastMessage = computed(
|
||||
() => errors.value.code || errors.value.turnstile || ''
|
||||
)
|
||||
|
||||
watch(validationToastMessage, (value, previousValue) => {
|
||||
if (value && value !== previousValue) {
|
||||
appStore.showError(value)
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -64,9 +64,6 @@
|
||||
:placeholder="t('auth.emailPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="errors.email" class="input-error-text">
|
||||
{{ errors.email }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Widget -->
|
||||
@@ -78,28 +75,8 @@
|
||||
@expire="onTurnstileExpire"
|
||||
@error="onTurnstileError"
|
||||
/>
|
||||
<p v-if="errors.turnstile" class="input-error-text mt-2 text-center">
|
||||
{{ errors.turnstile }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<Icon name="exclamationCircle" size="md" class="text-red-500" />
|
||||
</div>
|
||||
<p class="text-sm text-red-700 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
@@ -148,7 +125,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { computed, ref, reactive, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
@@ -185,6 +162,14 @@ const errors = reactive({
|
||||
turnstile: ''
|
||||
})
|
||||
|
||||
const validationToastMessage = computed(() => errors.email || errors.turnstile || '')
|
||||
|
||||
watch(validationToastMessage, (value, previousValue) => {
|
||||
if (value && value !== previousValue) {
|
||||
appStore.showError(value)
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -29,10 +29,10 @@
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Use LinuxDo profile details
|
||||
{{ t('auth.oauthFlow.profileDetailsTitle', { providerName }) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||
Choose whether to apply the nickname or avatar from LinuxDo to this account.
|
||||
{{ t('auth.oauthFlow.profileDetailsDescription', { providerName }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<input v-model="adoptDisplayName" type="checkbox" class="mt-1 h-4 w-4" />
|
||||
<span class="space-y-1">
|
||||
<span class="block font-medium text-gray-900 dark:text-white">
|
||||
Use display name
|
||||
{{ t('auth.oauthFlow.useDisplayName') }}
|
||||
</span>
|
||||
<span class="block text-gray-500 dark:text-dark-400">
|
||||
{{ suggestedDisplayName }}
|
||||
@@ -58,12 +58,12 @@
|
||||
<input v-model="adoptAvatar" type="checkbox" class="mt-1 h-4 w-4" />
|
||||
<img
|
||||
:src="suggestedAvatarUrl"
|
||||
alt="LinuxDo avatar"
|
||||
:alt="t('auth.oauthFlow.avatarAlt', { providerName })"
|
||||
class="h-10 w-10 rounded-full border border-gray-200 object-cover dark:border-dark-600"
|
||||
/>
|
||||
<span class="space-y-1">
|
||||
<span class="block font-medium text-gray-900 dark:text-white">
|
||||
Use avatar
|
||||
{{ t('auth.oauthFlow.useAvatar') }}
|
||||
</span>
|
||||
<span class="block break-all text-gray-500 dark:text-dark-400">
|
||||
{{ suggestedAvatarUrl }}
|
||||
@@ -87,11 +87,6 @@
|
||||
@keyup.enter="handleSubmitInvitation"
|
||||
/>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<p v-if="invitationError" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ invitationError }}
|
||||
</p>
|
||||
</transition>
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="isSubmitting || !invitationCode.trim()"
|
||||
@@ -103,10 +98,10 @@
|
||||
|
||||
<template v-else-if="needsAdoptionConfirmation">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Review the LinuxDo profile details before continuing.
|
||||
{{ t('auth.oauthFlow.reviewProfileBeforeContinue', { providerName }) }}
|
||||
</p>
|
||||
<button class="btn btn-primary w-full" :disabled="isSubmitting" @click="handleContinueLogin">
|
||||
{{ isSubmitting ? t('common.processing') : 'Continue' }}
|
||||
{{ isSubmitting ? t('common.processing') : t('auth.continue') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -115,13 +110,13 @@
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Choose how to continue
|
||||
{{ t('auth.oauthFlow.chooseHowToContinue') }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{
|
||||
pendingAccountEmail
|
||||
? `Suggested email: ${pendingAccountEmail}`
|
||||
: 'Choose whether to bind an existing account or create a new one.'
|
||||
? t('auth.oauthFlow.suggestedEmail', { email: pendingAccountEmail })
|
||||
: t('auth.oauthFlow.chooseAccountActionHint')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
@@ -132,14 +127,14 @@
|
||||
:disabled="isSubmitting"
|
||||
@click="switchToBindLoginMode()"
|
||||
>
|
||||
Bind existing account
|
||||
{{ t('auth.oauthFlow.bindExistingAccount') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="isSubmitting"
|
||||
@click="switchToCreateAccountMode"
|
||||
>
|
||||
Create new account
|
||||
{{ t('auth.oauthFlow.createNewAccount') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,7 +143,7 @@
|
||||
|
||||
<template v-else-if="needsCreateAccount">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Enter an email address to create your account and continue.
|
||||
{{ t('auth.oauthFlow.createAccountHint') }}
|
||||
</p>
|
||||
<PendingOAuthCreateAccountForm
|
||||
test-id-prefix="linuxdo"
|
||||
@@ -162,7 +157,7 @@
|
||||
|
||||
<template v-else-if="needsBindLogin">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Log in to an existing account to bind this LinuxDo sign-in.
|
||||
{{ t('auth.oauthFlow.bindLoginHint', { providerName }) }}
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<input
|
||||
@@ -170,7 +165,7 @@
|
||||
data-testid="linuxdo-bind-login-email"
|
||||
type="email"
|
||||
class="input w-full"
|
||||
placeholder="you@example.com"
|
||||
:placeholder="t('auth.emailPlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
@keyup.enter="handleBindLogin"
|
||||
/>
|
||||
@@ -179,7 +174,7 @@
|
||||
data-testid="linuxdo-bind-login-password"
|
||||
type="password"
|
||||
class="input w-full"
|
||||
placeholder="Password"
|
||||
:placeholder="t('auth.passwordPlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
@keyup.enter="handleBindLogin"
|
||||
/>
|
||||
@@ -189,7 +184,7 @@
|
||||
:disabled="isSubmitting || !bindLoginEmail.trim() || !bindLoginPassword"
|
||||
@click="handleBindLogin"
|
||||
>
|
||||
{{ isSubmitting ? t('common.processing') : 'Log in and bind' }}
|
||||
{{ isSubmitting ? t('common.processing') : t('auth.oauthFlow.logInAndBind') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canReturnToCreateAccount"
|
||||
@@ -197,21 +192,19 @@
|
||||
:disabled="isSubmitting"
|
||||
@click="switchToCreateAccountMode"
|
||||
>
|
||||
Use a different email
|
||||
{{ t('auth.oauthFlow.useDifferentEmail') }}
|
||||
</button>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<p v-if="accountActionError" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ accountActionError }}
|
||||
</p>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<template v-else-if="needsTotpChallenge">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Enter the 6-digit verification code for
|
||||
<span class="font-medium">{{ totpUserEmailMasked || 'your account' }}</span>
|
||||
to finish binding this LinuxDo sign-in.
|
||||
{{
|
||||
t('auth.oauthFlow.totpHint', {
|
||||
providerName,
|
||||
account: totpUserEmailMasked || t('auth.oauthFlow.yourAccount')
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<input
|
||||
@@ -231,51 +224,24 @@
|
||||
:disabled="isSubmitting || totpCode.trim().length !== 6"
|
||||
@click="handleSubmitTotpChallenge"
|
||||
>
|
||||
{{ isSubmitting ? t('common.processing') : 'Verify and continue' }}
|
||||
{{ isSubmitting ? t('common.processing') : t('auth.oauthFlow.verifyAndContinue') }}
|
||||
</button>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<p v-if="totpError" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ totpError }}
|
||||
</p>
|
||||
</transition>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<Icon name="exclamationCircle" size="md" class="text-red-500" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm text-red-700 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<router-link to="/login" class="btn btn-primary">
|
||||
{{ t('auth.linuxdo.backToLogin') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import PendingOAuthCreateAccountForm, {
|
||||
type PendingOAuthCreateAccountPayload
|
||||
} from '@/components/auth/PendingOAuthCreateAccountForm.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { apiClient } from '@/api/client'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import {
|
||||
@@ -325,11 +291,36 @@ const totpTempToken = ref('')
|
||||
const totpCode = ref('')
|
||||
const totpError = ref('')
|
||||
const totpUserEmailMasked = ref('')
|
||||
const providerName = 'LinuxDo'
|
||||
|
||||
const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account')
|
||||
const needsChooser = computed(() => pendingAccountAction.value === 'choose_account_action')
|
||||
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
|
||||
|
||||
watch(invitationError, value => {
|
||||
if (value) {
|
||||
appStore.showError(value)
|
||||
}
|
||||
})
|
||||
|
||||
watch(accountActionError, value => {
|
||||
if (value) {
|
||||
appStore.showError(value)
|
||||
}
|
||||
})
|
||||
|
||||
watch(totpError, value => {
|
||||
if (value) {
|
||||
appStore.showError(value)
|
||||
}
|
||||
})
|
||||
|
||||
watch(errorMessage, value => {
|
||||
if (value) {
|
||||
appStore.showError(value)
|
||||
}
|
||||
})
|
||||
|
||||
type LinuxDoPendingActionResponse = PendingOAuthExchangeResponse & {
|
||||
step?: string
|
||||
intent?: string
|
||||
@@ -542,6 +533,30 @@ function getRequestErrorMessage(error: unknown, fallback: string): string {
|
||||
return err.response?.data?.detail || err.response?.data?.message || err.message || fallback
|
||||
}
|
||||
|
||||
function isCreateAccountRecoveryError(error: unknown): boolean {
|
||||
const data = (error as {
|
||||
response?: {
|
||||
data?: {
|
||||
reason?: string
|
||||
error?: string
|
||||
code?: string
|
||||
step?: string
|
||||
intent?: string
|
||||
}
|
||||
}
|
||||
}).response?.data
|
||||
const states = [data?.reason, data?.error, data?.code, data?.step, data?.intent]
|
||||
.map(value => value?.trim().toLowerCase())
|
||||
.filter((value): value is string => Boolean(value))
|
||||
|
||||
return states.includes('email_exists') ||
|
||||
states.includes('bind_login_required') ||
|
||||
states.includes('bind_login') ||
|
||||
states.includes('adopt_existing_user_by_email') ||
|
||||
states.includes('existing_account_required') ||
|
||||
states.includes('existing_account_binding_required')
|
||||
}
|
||||
|
||||
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
|
||||
if (getOAuthCompletionKind(completion) === 'bind') {
|
||||
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
|
||||
@@ -629,7 +644,6 @@ async function handleContinueLogin() {
|
||||
await finalizePendingAccountResponse(completion)
|
||||
} catch (e: unknown) {
|
||||
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||
appStore.showError(errorMessage.value)
|
||||
needsAdoptionConfirmation.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
@@ -651,6 +665,10 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
|
||||
})
|
||||
await finalizePendingAccountResponse(data)
|
||||
} catch (e: unknown) {
|
||||
if (isCreateAccountRecoveryError(e)) {
|
||||
switchToBindLoginMode(payload.email.trim())
|
||||
return
|
||||
}
|
||||
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
@@ -728,7 +746,6 @@ onMounted(async () => {
|
||||
|
||||
if (error) {
|
||||
errorMessage.value = errorDesc || error
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
@@ -770,7 +787,6 @@ onMounted(async () => {
|
||||
} catch (e: unknown) {
|
||||
clearPendingAuthSession()
|
||||
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -61,9 +61,6 @@
|
||||
:placeholder="t('auth.emailPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="errors.email" class="input-error-text">
|
||||
{{ errors.email }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Password Input -->
|
||||
@@ -96,10 +93,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center justify-between">
|
||||
<p v-if="errors.password" class="input-error-text">
|
||||
{{ errors.password }}
|
||||
</p>
|
||||
<span v-else></span>
|
||||
<span></span>
|
||||
<router-link
|
||||
v-if="passwordResetEnabled && !backendModeEnabled"
|
||||
to="/forgot-password"
|
||||
@@ -119,28 +113,8 @@
|
||||
@expire="onTurnstileExpire"
|
||||
@error="onTurnstileError"
|
||||
/>
|
||||
<p v-if="errors.turnstile" class="input-error-text mt-2 text-center">
|
||||
{{ errors.turnstile }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<Icon name="exclamationCircle" size="md" class="text-red-500" />
|
||||
</div>
|
||||
<p class="text-sm text-red-700 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
@@ -199,7 +173,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { computed, ref, reactive, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
@@ -258,6 +232,16 @@ const errors = reactive({
|
||||
turnstile: ''
|
||||
})
|
||||
|
||||
const validationToastMessage = computed(
|
||||
() => errors.email || errors.password || errors.turnstile || ''
|
||||
)
|
||||
|
||||
watch(validationToastMessage, (value, previousValue) => {
|
||||
if (value && value !== previousValue) {
|
||||
appStore.showError(value)
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
<div class="min-h-screen bg-gray-50 px-4 py-10 dark:bg-dark-900">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<div class="card p-6">
|
||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">OAuth Callback</h1>
|
||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('auth.oauth.callbackTitle') }}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Copy the <code>code</code> (and <code>state</code> if needed) back to the admin
|
||||
authorization flow.
|
||||
{{ t('auth.oauth.callbackHint') }}
|
||||
</p>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
@@ -14,7 +15,7 @@
|
||||
<div class="flex gap-2">
|
||||
<input class="input flex-1 font-mono text-sm" :value="code" readonly />
|
||||
<button class="btn btn-secondary" type="button" :disabled="!code" @click="copy(code)">
|
||||
Copy
|
||||
{{ t('common.copy') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,7 +30,7 @@
|
||||
:disabled="!state"
|
||||
@click="copy(state)"
|
||||
>
|
||||
Copy
|
||||
{{ t('common.copy') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,17 +45,10 @@
|
||||
:disabled="!fullUrl"
|
||||
@click="copy(fullUrl)"
|
||||
>
|
||||
Copy
|
||||
{{ t('common.copy') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="error"
|
||||
class="rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
|
||||
>
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,14 +56,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useAppStore } from '@/stores'
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const code = computed(() => (route.query.code as string) || '')
|
||||
const state = computed(() => (route.query.state as string) || '')
|
||||
@@ -82,8 +78,18 @@ const fullUrl = computed(() => {
|
||||
return window.location.href
|
||||
})
|
||||
|
||||
watch(
|
||||
error,
|
||||
(message) => {
|
||||
if (message) {
|
||||
appStore.showError(message)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const copy = (value: string) => {
|
||||
if (!value) return
|
||||
copyToClipboard(value, 'Copied')
|
||||
copyToClipboard(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -33,11 +33,10 @@
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Use {{ providerName }} profile details
|
||||
{{ t('auth.oauthFlow.profileDetailsTitle', { providerName }) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||
Choose whether to apply the nickname or avatar from {{ providerName }} to this
|
||||
account.
|
||||
{{ t('auth.oauthFlow.profileDetailsDescription', { providerName }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -48,7 +47,7 @@
|
||||
<input v-model="adoptDisplayName" type="checkbox" class="mt-1 h-4 w-4" />
|
||||
<span class="space-y-1">
|
||||
<span class="block font-medium text-gray-900 dark:text-white">
|
||||
Use display name
|
||||
{{ t('auth.oauthFlow.useDisplayName') }}
|
||||
</span>
|
||||
<span class="block text-gray-500 dark:text-dark-400">
|
||||
{{ suggestedDisplayName }}
|
||||
@@ -63,12 +62,12 @@
|
||||
<input v-model="adoptAvatar" type="checkbox" class="mt-1 h-4 w-4" />
|
||||
<img
|
||||
:src="suggestedAvatarUrl"
|
||||
:alt="`${providerName} avatar`"
|
||||
:alt="t('auth.oauthFlow.avatarAlt', { providerName })"
|
||||
class="h-10 w-10 rounded-full border border-gray-200 object-cover dark:border-dark-600"
|
||||
/>
|
||||
<span class="space-y-1">
|
||||
<span class="block font-medium text-gray-900 dark:text-white">
|
||||
Use avatar
|
||||
{{ t('auth.oauthFlow.useAvatar') }}
|
||||
</span>
|
||||
<span class="block break-all text-gray-500 dark:text-dark-400">
|
||||
{{ suggestedAvatarUrl }}
|
||||
@@ -92,11 +91,6 @@
|
||||
@keyup.enter="handleSubmitInvitation"
|
||||
/>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<p v-if="invitationError" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ invitationError }}
|
||||
</p>
|
||||
</transition>
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="isSubmitting || !invitationCode.trim()"
|
||||
@@ -112,10 +106,10 @@
|
||||
|
||||
<template v-else-if="needsAdoptionConfirmation">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Review the {{ providerName }} profile details before continuing.
|
||||
{{ t('auth.oauthFlow.reviewProfileBeforeContinue', { providerName }) }}
|
||||
</p>
|
||||
<button class="btn btn-primary w-full" :disabled="isSubmitting" @click="handleContinueLogin">
|
||||
{{ isSubmitting ? t('common.processing') : 'Continue' }}
|
||||
{{ isSubmitting ? t('common.processing') : t('auth.continue') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -124,13 +118,13 @@
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Choose how to continue
|
||||
{{ t('auth.oauthFlow.chooseHowToContinue') }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{
|
||||
pendingAccountEmail
|
||||
? `Suggested email: ${pendingAccountEmail}`
|
||||
: `Choose whether to bind an existing ${providerName} account or create a new one.`
|
||||
? t('auth.oauthFlow.suggestedEmail', { email: pendingAccountEmail })
|
||||
: t('auth.oauthFlow.chooseAccountActionHint')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
@@ -141,14 +135,14 @@
|
||||
:disabled="isSubmitting"
|
||||
@click="switchToBindLoginMode()"
|
||||
>
|
||||
Bind existing account
|
||||
{{ t('auth.oauthFlow.bindExistingAccount') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="isSubmitting"
|
||||
@click="switchToCreateAccountMode"
|
||||
>
|
||||
Create new account
|
||||
{{ t('auth.oauthFlow.createNewAccount') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,7 +151,7 @@
|
||||
|
||||
<template v-else-if="needsCreateAccount">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Enter an email address to create your account and continue.
|
||||
{{ t('auth.oauthFlow.createAccountHint') }}
|
||||
</p>
|
||||
<PendingOAuthCreateAccountForm
|
||||
test-id-prefix="oidc"
|
||||
@@ -171,7 +165,7 @@
|
||||
|
||||
<template v-else-if="needsBindLogin">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Log in to an existing account to bind this {{ providerName }} sign-in.
|
||||
{{ t('auth.oauthFlow.bindLoginHint', { providerName }) }}
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<input
|
||||
@@ -179,7 +173,7 @@
|
||||
data-testid="oidc-bind-login-email"
|
||||
type="email"
|
||||
class="input w-full"
|
||||
placeholder="you@example.com"
|
||||
:placeholder="t('auth.emailPlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
@keyup.enter="handleBindLogin"
|
||||
/>
|
||||
@@ -188,7 +182,7 @@
|
||||
data-testid="oidc-bind-login-password"
|
||||
type="password"
|
||||
class="input w-full"
|
||||
placeholder="Password"
|
||||
:placeholder="t('auth.passwordPlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
@keyup.enter="handleBindLogin"
|
||||
/>
|
||||
@@ -198,7 +192,7 @@
|
||||
:disabled="isSubmitting || !bindLoginEmail.trim() || !bindLoginPassword"
|
||||
@click="handleBindLogin"
|
||||
>
|
||||
{{ isSubmitting ? t('common.processing') : 'Log in and bind' }}
|
||||
{{ isSubmitting ? t('common.processing') : t('auth.oauthFlow.logInAndBind') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canReturnToCreateAccount"
|
||||
@@ -206,21 +200,19 @@
|
||||
:disabled="isSubmitting"
|
||||
@click="switchToCreateAccountMode"
|
||||
>
|
||||
Use a different email
|
||||
{{ t('auth.oauthFlow.useDifferentEmail') }}
|
||||
</button>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<p v-if="accountActionError" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ accountActionError }}
|
||||
</p>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<template v-else-if="needsTotpChallenge">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Enter the 6-digit verification code for
|
||||
<span class="font-medium">{{ totpUserEmailMasked || 'your account' }}</span>
|
||||
to finish binding this {{ providerName }} sign-in.
|
||||
{{
|
||||
t('auth.oauthFlow.totpHint', {
|
||||
providerName,
|
||||
account: totpUserEmailMasked || t('auth.oauthFlow.yourAccount')
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<input
|
||||
@@ -240,51 +232,24 @@
|
||||
:disabled="isSubmitting || totpCode.trim().length !== 6"
|
||||
@click="handleSubmitTotpChallenge"
|
||||
>
|
||||
{{ isSubmitting ? t('common.processing') : 'Verify and continue' }}
|
||||
{{ isSubmitting ? t('common.processing') : t('auth.oauthFlow.verifyAndContinue') }}
|
||||
</button>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<p v-if="totpError" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ totpError }}
|
||||
</p>
|
||||
</transition>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<Icon name="exclamationCircle" size="md" class="text-red-500" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm text-red-700 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<router-link to="/login" class="btn btn-primary">
|
||||
{{ t('auth.oidc.backToLogin') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import PendingOAuthCreateAccountForm, {
|
||||
type PendingOAuthCreateAccountPayload
|
||||
} from '@/components/auth/PendingOAuthCreateAccountForm.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { apiClient } from '@/api/client'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import {
|
||||
@@ -339,6 +304,30 @@ const needsCreateAccount = computed(() => pendingAccountAction.value === 'create
|
||||
const needsChooser = computed(() => pendingAccountAction.value === 'choose_account_action')
|
||||
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
|
||||
|
||||
watch(invitationError, value => {
|
||||
if (value) {
|
||||
appStore.showError(value)
|
||||
}
|
||||
})
|
||||
|
||||
watch(accountActionError, value => {
|
||||
if (value) {
|
||||
appStore.showError(value)
|
||||
}
|
||||
})
|
||||
|
||||
watch(totpError, value => {
|
||||
if (value) {
|
||||
appStore.showError(value)
|
||||
}
|
||||
})
|
||||
|
||||
watch(errorMessage, value => {
|
||||
if (value) {
|
||||
appStore.showError(value)
|
||||
}
|
||||
})
|
||||
|
||||
type PendingOidcCompletion = PendingOAuthExchangeResponse & {
|
||||
step?: string
|
||||
pending_email?: string
|
||||
@@ -573,6 +562,30 @@ function getRequestErrorMessage(error: unknown, fallback: string): string {
|
||||
return err.response?.data?.detail || err.response?.data?.message || err.message || fallback
|
||||
}
|
||||
|
||||
function isCreateAccountRecoveryError(error: unknown): boolean {
|
||||
const data = (error as {
|
||||
response?: {
|
||||
data?: {
|
||||
reason?: string
|
||||
error?: string
|
||||
code?: string
|
||||
step?: string
|
||||
intent?: string
|
||||
}
|
||||
}
|
||||
}).response?.data
|
||||
const states = [data?.reason, data?.error, data?.code, data?.step, data?.intent]
|
||||
.map(value => value?.trim().toLowerCase())
|
||||
.filter((value): value is string => Boolean(value))
|
||||
|
||||
return states.includes('email_exists') ||
|
||||
states.includes('bind_login_required') ||
|
||||
states.includes('bind_login') ||
|
||||
states.includes('adopt_existing_user_by_email') ||
|
||||
states.includes('existing_account_required') ||
|
||||
states.includes('existing_account_binding_required')
|
||||
}
|
||||
|
||||
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
|
||||
if (getOAuthCompletionKind(completion) === 'bind') {
|
||||
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
|
||||
@@ -660,7 +673,6 @@ async function handleContinueLogin() {
|
||||
await finalizePendingAccountResponse(completion)
|
||||
} catch (e: unknown) {
|
||||
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||
appStore.showError(errorMessage.value)
|
||||
needsAdoptionConfirmation.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
@@ -682,6 +694,10 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
|
||||
})
|
||||
await finalizePendingAccountResponse(data)
|
||||
} catch (e: unknown) {
|
||||
if (isCreateAccountRecoveryError(e)) {
|
||||
switchToBindLoginMode(payload.email.trim())
|
||||
return
|
||||
}
|
||||
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
@@ -761,7 +777,6 @@ onMounted(async () => {
|
||||
|
||||
if (error) {
|
||||
errorMessage.value = errorDesc || error
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
@@ -803,7 +818,6 @@ onMounted(async () => {
|
||||
} catch (e: unknown) {
|
||||
clearPendingAuthSession()
|
||||
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -76,9 +76,6 @@
|
||||
:placeholder="t('auth.emailPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="errors.email" class="input-error-text">
|
||||
{{ errors.email }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Password Input -->
|
||||
@@ -110,9 +107,6 @@
|
||||
<Icon v-else name="eye" size="md" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="errors.password" class="input-error-text">
|
||||
{{ errors.password }}
|
||||
</p>
|
||||
<p v-else class="input-hint">
|
||||
{{ t('auth.passwordHint') }}
|
||||
</p>
|
||||
@@ -162,12 +156,6 @@
|
||||
{{ t('auth.invitationCodeValid') }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-else-if="invitationValidation.invalid" class="input-error-text">
|
||||
{{ invitationValidation.message }}
|
||||
</p>
|
||||
<p v-else-if="errors.invitation_code" class="input-error-text">
|
||||
{{ errors.invitation_code }}
|
||||
</p>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
@@ -216,9 +204,6 @@
|
||||
{{ t('auth.promoCodeValid', { amount: promoValidation.bonusAmount?.toFixed(2) }) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-else-if="promoValidation.invalid" class="input-error-text">
|
||||
{{ promoValidation.message }}
|
||||
</p>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
@@ -231,28 +216,8 @@
|
||||
@expire="onTurnstileExpire"
|
||||
@error="onTurnstileError"
|
||||
/>
|
||||
<p v-if="errors.turnstile" class="input-error-text mt-2 text-center">
|
||||
{{ errors.turnstile }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<Icon name="exclamationCircle" size="md" class="text-red-500" />
|
||||
</div>
|
||||
<p class="text-sm text-red-700 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
@@ -307,7 +272,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { computed, ref, reactive, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
@@ -391,6 +356,22 @@ const errors = reactive({
|
||||
invitation_code: ''
|
||||
})
|
||||
|
||||
const validationToastMessage = computed(() =>
|
||||
errors.email ||
|
||||
errors.password ||
|
||||
(invitationValidation.invalid ? invitationValidation.message : '') ||
|
||||
errors.invitation_code ||
|
||||
(promoValidation.invalid ? promoValidation.message : '') ||
|
||||
errors.turnstile ||
|
||||
''
|
||||
)
|
||||
|
||||
watch(validationToastMessage, (value, previousValue) => {
|
||||
if (value && value !== previousValue) {
|
||||
appStore.showError(value)
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -13,16 +13,16 @@
|
||||
|
||||
<!-- Invalid Link State -->
|
||||
<div v-if="isInvalidLink" class="space-y-6">
|
||||
<div class="rounded-xl border border-red-200 bg-red-50 p-6 dark:border-red-800/50 dark:bg-red-900/20">
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 p-6 dark:border-amber-800/50 dark:bg-amber-900/20">
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-800/50">
|
||||
<Icon name="exclamationCircle" size="lg" class="text-red-600 dark:text-red-400" />
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-800/50">
|
||||
<Icon name="exclamationCircle" size="lg" class="text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-red-800 dark:text-red-200">
|
||||
<h3 class="text-lg font-semibold text-amber-800 dark:text-amber-200">
|
||||
{{ t('auth.invalidResetLink') }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-red-700 dark:text-red-300">
|
||||
<p class="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
||||
{{ t('auth.invalidResetLinkHint') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -119,9 +119,6 @@
|
||||
<Icon v-else name="eye" size="md" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="errors.password" class="input-error-text">
|
||||
{{ errors.password }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password Input -->
|
||||
@@ -153,28 +150,8 @@
|
||||
<Icon v-else name="eye" size="md" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="errors.confirmPassword" class="input-error-text">
|
||||
{{ errors.confirmPassword }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<Icon name="exclamationCircle" size="md" class="text-red-500" />
|
||||
</div>
|
||||
<p class="text-sm text-red-700 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
@@ -223,7 +200,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
@@ -260,6 +237,16 @@ const errors = reactive({
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const validationToastMessage = computed(
|
||||
() => errors.password || errors.confirmPassword || ''
|
||||
)
|
||||
|
||||
watch(validationToastMessage, (value, previousValue) => {
|
||||
if (value && value !== previousValue) {
|
||||
appStore.showError(value)
|
||||
}
|
||||
})
|
||||
|
||||
// Check if the reset link is valid (has email and token)
|
||||
const isInvalidLink = computed(() => !email.value || !token.value)
|
||||
|
||||
@@ -269,6 +256,10 @@ onMounted(() => {
|
||||
// Get email and token from URL query parameters
|
||||
email.value = (route.query.email as string) || ''
|
||||
token.value = (route.query.token as string) || ''
|
||||
|
||||
if (!email.value || !token.value) {
|
||||
appStore.showError(t('auth.invalidResetLink'))
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== Validation ====================
|
||||
|
||||
@@ -33,10 +33,10 @@
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Use {{ providerName }} profile details
|
||||
{{ t('auth.oauthFlow.profileDetailsTitle', { providerName }) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||
Choose whether to apply the nickname or avatar from {{ providerName }} to this account.
|
||||
{{ t('auth.oauthFlow.profileDetailsDescription', { providerName }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<input v-model="adoptDisplayName" type="checkbox" class="mt-1 h-4 w-4" />
|
||||
<span class="space-y-1">
|
||||
<span class="block font-medium text-gray-900 dark:text-white">
|
||||
Use display name
|
||||
{{ t('auth.oauthFlow.useDisplayName') }}
|
||||
</span>
|
||||
<span class="block text-gray-500 dark:text-dark-400">
|
||||
{{ suggestedDisplayName }}
|
||||
@@ -62,12 +62,12 @@
|
||||
<input v-model="adoptAvatar" type="checkbox" class="mt-1 h-4 w-4" />
|
||||
<img
|
||||
:src="suggestedAvatarUrl"
|
||||
:alt="`${providerName} avatar`"
|
||||
:alt="t('auth.oauthFlow.avatarAlt', { providerName })"
|
||||
class="h-10 w-10 rounded-full border border-gray-200 object-cover dark:border-dark-600"
|
||||
/>
|
||||
<span class="space-y-1">
|
||||
<span class="block font-medium text-gray-900 dark:text-white">
|
||||
Use avatar
|
||||
{{ t('auth.oauthFlow.useAvatar') }}
|
||||
</span>
|
||||
<span class="block break-all text-gray-500 dark:text-dark-400">
|
||||
{{ suggestedAvatarUrl }}
|
||||
@@ -91,11 +91,6 @@
|
||||
@keyup.enter="handleSubmitInvitation"
|
||||
/>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<p v-if="invitationError" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ invitationError }}
|
||||
</p>
|
||||
</transition>
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="isSubmitting || !invitationCode.trim()"
|
||||
@@ -119,8 +114,8 @@
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{
|
||||
hasCurrentAuthToken
|
||||
? 'Bind this WeChat identity to the account currently signed in on this browser.'
|
||||
: 'Sign in to an existing account, then bind this WeChat identity to it.'
|
||||
? t('auth.oauthFlow.bindCurrentAccountDescription', { providerName })
|
||||
: t('auth.oauthFlow.signInThenBindDescription', { providerName })
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
@@ -142,7 +137,7 @@
|
||||
:disabled="isSubmitting"
|
||||
@click="handleExistingAccountBinding"
|
||||
>
|
||||
{{ hasCurrentAuthToken ? 'Bind current account' : t('auth.signIn') }}
|
||||
{{ hasCurrentAuthToken ? t('auth.oauthFlow.bindCurrentAccount') : t('auth.signIn') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -155,10 +150,10 @@
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Choose how to continue
|
||||
{{ t('auth.oauthFlow.chooseHowToContinue') }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||
Pick whether to bind an existing account or create a new one.
|
||||
{{ t('auth.oauthFlow.chooseAccountActionHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -169,7 +164,7 @@
|
||||
:disabled="isSubmitting"
|
||||
@click="switchToBindLoginMode()"
|
||||
>
|
||||
Bind existing account
|
||||
{{ t('auth.oauthFlow.bindExistingAccount') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -179,7 +174,7 @@
|
||||
:disabled="isSubmitting"
|
||||
@click="switchToCreateAccountMode()"
|
||||
>
|
||||
Create new account
|
||||
{{ t('auth.oauthFlow.createNewAccount') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -187,16 +182,16 @@
|
||||
|
||||
<template v-else-if="needsAdoptionConfirmation">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Review the {{ providerName }} profile details before continuing.
|
||||
{{ t('auth.oauthFlow.reviewProfileBeforeContinue', { providerName }) }}
|
||||
</p>
|
||||
<button class="btn btn-primary w-full" :disabled="isSubmitting" @click="handleContinueLogin">
|
||||
{{ isSubmitting ? t('common.processing') : 'Continue' }}
|
||||
{{ isSubmitting ? t('common.processing') : t('auth.continue') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="needsCreateAccount">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Enter an email address to create your account and continue.
|
||||
{{ t('auth.oauthFlow.createAccountHint') }}
|
||||
</p>
|
||||
<PendingOAuthCreateAccountForm
|
||||
test-id-prefix="wechat"
|
||||
@@ -210,15 +205,15 @@
|
||||
v-if="showBackToChooser"
|
||||
class="btn btn-secondary w-full"
|
||||
:disabled="isSubmitting"
|
||||
@click="switchToChoiceMode"
|
||||
@click="switchToCreateAccountMode()"
|
||||
>
|
||||
Back to options
|
||||
{{ t('auth.oauthFlow.createNewAccount') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="needsBindLogin">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Bind this {{ providerName }} sign-in to an existing account.
|
||||
{{ t('auth.oauthFlow.bindSignInToExistingAccount', { providerName }) }}
|
||||
</p>
|
||||
<div
|
||||
v-if="hasCurrentAuthToken"
|
||||
@@ -227,10 +222,10 @@
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Bind the current account
|
||||
{{ t('auth.oauthFlow.bindCurrentAccountTitle') }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||
Bind this WeChat identity to the account currently signed in on this browser.
|
||||
{{ t('auth.oauthFlow.bindCurrentAccountDescription', { providerName }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -241,7 +236,7 @@
|
||||
:disabled="isSubmitting"
|
||||
@click="handleBindCurrentAccount"
|
||||
>
|
||||
{{ isSubmitting ? t('common.processing') : 'Bind current account' }}
|
||||
{{ isSubmitting ? t('common.processing') : t('auth.oauthFlow.bindCurrentAccount') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -251,7 +246,7 @@
|
||||
data-testid="wechat-bind-login-email"
|
||||
type="email"
|
||||
class="input w-full"
|
||||
placeholder="you@example.com"
|
||||
:placeholder="t('auth.emailPlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
@keyup.enter="handleBindLogin"
|
||||
/>
|
||||
@@ -260,7 +255,7 @@
|
||||
data-testid="wechat-bind-login-password"
|
||||
type="password"
|
||||
class="input w-full"
|
||||
placeholder="Password"
|
||||
:placeholder="t('auth.passwordPlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
@keyup.enter="handleBindLogin"
|
||||
/>
|
||||
@@ -270,24 +265,27 @@
|
||||
:disabled="isSubmitting || !bindLoginEmail.trim() || !bindLoginPassword"
|
||||
@click="handleBindLogin"
|
||||
>
|
||||
{{ isSubmitting ? t('common.processing') : 'Log in and bind' }}
|
||||
{{ isSubmitting ? t('common.processing') : t('auth.oauthFlow.logInAndBind') }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="showBackToChooser"
|
||||
class="btn btn-secondary w-full"
|
||||
:disabled="isSubmitting"
|
||||
@click="switchToChoiceMode"
|
||||
@click="switchToCreateAccountMode()"
|
||||
>
|
||||
Back to options
|
||||
{{ t('auth.oauthFlow.createNewAccount') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="needsTotpChallenge">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Enter the 6-digit verification code for
|
||||
<span class="font-medium">{{ totpUserEmailMasked || 'your account' }}</span>
|
||||
to finish binding this {{ providerName }} sign-in.
|
||||
{{
|
||||
t('auth.oauthFlow.totpHint', {
|
||||
providerName,
|
||||
account: totpUserEmailMasked || t('auth.oauthFlow.yourAccount')
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<input
|
||||
@@ -307,57 +305,24 @@
|
||||
:disabled="isSubmitting || totpCode.trim().length !== 6"
|
||||
@click="handleSubmitTotpChallenge"
|
||||
>
|
||||
{{ isSubmitting ? t('common.processing') : 'Verify and continue' }}
|
||||
{{ isSubmitting ? t('common.processing') : t('auth.oauthFlow.verifyAndContinue') }}
|
||||
</button>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<p v-if="totpError" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ totpError }}
|
||||
</p>
|
||||
</transition>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<transition name="fade">
|
||||
<p v-if="accountActionError" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ accountActionError }}
|
||||
</p>
|
||||
</transition>
|
||||
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<Icon name="exclamationCircle" size="md" class="text-red-500" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm text-red-700 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<router-link to="/login" class="btn btn-primary">
|
||||
{{ t('auth.oidc.backToLogin') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import PendingOAuthCreateAccountForm, {
|
||||
type PendingOAuthCreateAccountPayload
|
||||
} from '@/components/auth/PendingOAuthCreateAccountForm.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { apiClient } from '@/api/client'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import {
|
||||
@@ -411,7 +376,7 @@ const totpError = ref('')
|
||||
const totpUserEmailMasked = ref('')
|
||||
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
|
||||
|
||||
const providerName = 'WeChat'
|
||||
const providerName = t('auth.wechatProviderName')
|
||||
const showBackToChooser = computed(
|
||||
() => pendingAccountAction.value === 'create_account' || pendingAccountAction.value === 'bind_login'
|
||||
)
|
||||
@@ -419,6 +384,30 @@ const needsCreateAccount = computed(() => pendingAccountAction.value === 'create
|
||||
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
|
||||
const hasCurrentAuthToken = computed(() => Boolean(getAuthToken()))
|
||||
|
||||
watch(invitationError, value => {
|
||||
if (value) {
|
||||
appStore.showError(value)
|
||||
}
|
||||
})
|
||||
|
||||
watch(accountActionError, value => {
|
||||
if (value) {
|
||||
appStore.showError(value)
|
||||
}
|
||||
})
|
||||
|
||||
watch(totpError, value => {
|
||||
if (value) {
|
||||
appStore.showError(value)
|
||||
}
|
||||
})
|
||||
|
||||
watch(errorMessage, value => {
|
||||
if (value) {
|
||||
appStore.showError(value)
|
||||
}
|
||||
})
|
||||
|
||||
type PendingWeChatCompletion = PendingOAuthExchangeResponse & {
|
||||
step?: string
|
||||
status?: string
|
||||
@@ -510,13 +499,13 @@ function resolveWeChatOAuthUnavailableMessage(): string {
|
||||
|
||||
switch (resolved.unavailableReason) {
|
||||
case 'capability_unknown':
|
||||
return 'WeChat sign-in availability could not be confirmed. Refresh and retry.'
|
||||
return t('auth.oauthFlow.wechatAvailabilityUnknown')
|
||||
case 'external_browser_required':
|
||||
return 'This WeChat sign-in flow is only available in your system browser.'
|
||||
return t('auth.oauthFlow.wechatSystemBrowserOnly')
|
||||
case 'wechat_browser_required':
|
||||
return 'This WeChat sign-in flow is only available inside the WeChat browser.'
|
||||
return t('auth.oauthFlow.wechatBrowserOnly')
|
||||
case 'not_configured':
|
||||
return 'WeChat sign-in is not configured yet.'
|
||||
return t('auth.oauthFlow.wechatNotConfigured')
|
||||
default:
|
||||
return t('auth.loginFailed')
|
||||
}
|
||||
@@ -619,7 +608,6 @@ async function handleBindCurrentAccount() {
|
||||
const startURL = resolveWeChatStartURL('bind_current_user')
|
||||
if (!startURL) {
|
||||
errorMessage.value = unavailableMessage || resolveWeChatOAuthUnavailableMessage()
|
||||
appStore.showError(errorMessage.value)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -636,7 +624,6 @@ async function handleExistingAccountBinding() {
|
||||
const resumePath = buildExistingAccountResumePath()
|
||||
if (!resumePath) {
|
||||
errorMessage.value = resolveWeChatOAuthUnavailableMessage()
|
||||
appStore.showError(errorMessage.value)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -693,11 +680,7 @@ function resolvePendingAccountAction(
|
||||
raw === 'choose_account_action_required' ||
|
||||
raw === 'choose_account_action' ||
|
||||
raw === 'choose_account' ||
|
||||
raw === 'choose' ||
|
||||
raw === 'existing_account' ||
|
||||
raw === 'existing_account_required' ||
|
||||
raw === 'existing_account_binding_required' ||
|
||||
raw === 'adopt_existing_user_by_email'
|
||||
raw === 'choose'
|
||||
) {
|
||||
return 'choice'
|
||||
}
|
||||
@@ -705,6 +688,10 @@ function resolvePendingAccountAction(
|
||||
return 'create_account'
|
||||
}
|
||||
if (
|
||||
raw === 'existing_account' ||
|
||||
raw === 'existing_account_required' ||
|
||||
raw === 'existing_account_binding_required' ||
|
||||
raw === 'adopt_existing_user_by_email' ||
|
||||
raw === 'bind_login_required' ||
|
||||
raw === 'bind_login'
|
||||
) {
|
||||
@@ -776,13 +763,6 @@ function switchToCreateAccountMode() {
|
||||
accountActionError.value = ''
|
||||
}
|
||||
|
||||
function switchToChoiceMode() {
|
||||
pendingAccountAction.value = 'choice'
|
||||
needsChooser.value = true
|
||||
bindLoginPassword.value = ''
|
||||
accountActionError.value = ''
|
||||
}
|
||||
|
||||
function getRequestErrorMessage(error: unknown, fallback: string): string {
|
||||
const err = error as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||
return err.response?.data?.detail || err.response?.data?.message || err.message || fallback
|
||||
@@ -899,7 +879,6 @@ async function handleContinueLogin() {
|
||||
await finalizePendingAccountResponse(completion)
|
||||
} catch (e: unknown) {
|
||||
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||
appStore.showError(errorMessage.value)
|
||||
needsAdoptionConfirmation.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
@@ -922,10 +901,7 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
|
||||
await finalizePendingAccountResponse(data)
|
||||
} catch (e: unknown) {
|
||||
if (isCreateAccountRecoveryError(e)) {
|
||||
switchToChoiceMode()
|
||||
pendingAccountEmail.value = payload.email.trim()
|
||||
bindLoginEmail.value = payload.email.trim()
|
||||
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||
switchToBindLoginMode(payload.email.trim())
|
||||
return
|
||||
}
|
||||
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||
@@ -1000,7 +976,6 @@ onMounted(async () => {
|
||||
const resumePath = buildExistingAccountResumePath()
|
||||
if (!resumePath) {
|
||||
errorMessage.value = resolveWeChatOAuthUnavailableMessage()
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
@@ -1044,7 +1019,6 @@ onMounted(async () => {
|
||||
|
||||
if (error) {
|
||||
errorMessage.value = errorDesc || error
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
@@ -1086,7 +1060,6 @@ onMounted(async () => {
|
||||
} catch (e: unknown) {
|
||||
clearPendingAuthSession()
|
||||
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="mt-6 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-700/50 dark:bg-red-900/20"
|
||||
class="mt-6 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-700 dark:bg-dark-800/80"
|
||||
>
|
||||
<p class="text-sm text-red-700 dark:text-red-400">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<button
|
||||
@@ -39,40 +39,27 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const errorMessage = ref('')
|
||||
|
||||
function textWithFallback(key: string, zh: string, en: string): string {
|
||||
const translated = t(key)
|
||||
if (translated !== key) return translated
|
||||
return String(locale.value).toLowerCase().startsWith('zh') ? zh : en
|
||||
}
|
||||
watch(errorMessage, (message) => {
|
||||
if (message) {
|
||||
appStore.showError(message)
|
||||
}
|
||||
})
|
||||
|
||||
const callbackProcessingText = computed(() =>
|
||||
textWithFallback(
|
||||
'auth.wechatPayment.callbackProcessing',
|
||||
'正在恢复微信支付...',
|
||||
'Resuming WeChat payment...',
|
||||
))
|
||||
const callbackTitleText = computed(() =>
|
||||
textWithFallback(
|
||||
'auth.wechatPayment.callbackTitle',
|
||||
'正在恢复微信支付',
|
||||
'Resuming WeChat payment',
|
||||
))
|
||||
const backToPaymentText = computed(() =>
|
||||
textWithFallback(
|
||||
'auth.wechatPayment.backToPayment',
|
||||
'返回支付页',
|
||||
'Back to payment',
|
||||
))
|
||||
const callbackProcessingText = computed(() => t('auth.wechatPayment.callbackProcessing'))
|
||||
const callbackTitleText = computed(() => t('auth.wechatPayment.callbackTitle'))
|
||||
const backToPaymentText = computed(() => t('auth.wechatPayment.backToPayment'))
|
||||
|
||||
function readQueryString(key: string): string {
|
||||
const value = route.query[key]
|
||||
@@ -121,11 +108,7 @@ onMounted(async () => {
|
||||
)
|
||||
|
||||
if (!resumeToken) {
|
||||
errorMessage.value = textWithFallback(
|
||||
'auth.wechatPayment.callbackMissingResumeToken',
|
||||
'微信支付回调缺少恢复令牌。',
|
||||
'The WeChat payment callback is missing the resume token.',
|
||||
)
|
||||
errorMessage.value = t('auth.wechatPayment.callbackMissingResumeToken')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,12 @@ vi.mock('vue-i18n', async () => {
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
if (key === 'auth.oauthFlow.totpHint') {
|
||||
return `verify ${params?.account ?? ''}`.trim()
|
||||
}
|
||||
return key
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -498,6 +503,34 @@ describe('LinuxDoCallbackView', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('shows create-account failures through toast without inline error text', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||
error: 'email_required',
|
||||
redirect: '/welcome'
|
||||
})
|
||||
apiClientPost.mockRejectedValue(new Error('create failed'))
|
||||
|
||||
const wrapper = mount(LinuxDoCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-testid="linuxdo-create-account-email"]').setValue('new@example.com')
|
||||
await wrapper.get('[data-testid="linuxdo-create-account-password"]').setValue('secret-123')
|
||||
await wrapper.get('[data-testid="linuxdo-create-account-submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(showError).toHaveBeenCalledWith('create failed')
|
||||
expect(wrapper.text()).not.toContain('create failed')
|
||||
})
|
||||
|
||||
it('sends a verify code for pending oauth account creation', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||
error: 'email_required',
|
||||
|
||||
68
frontend/src/views/auth/__tests__/OAuthCallbackView.spec.ts
Normal file
68
frontend/src/views/auth/__tests__/OAuthCallbackView.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import OAuthCallbackView from '@/views/auth/OAuthCallbackView.vue'
|
||||
|
||||
const { routeState, showErrorMock, copyToClipboardMock } = vi.hoisted(() => ({
|
||||
routeState: {
|
||||
query: {} as Record<string, unknown>,
|
||||
},
|
||||
showErrorMock: vi.fn(),
|
||||
copyToClipboardMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => routeState,
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useAppStore: () => ({
|
||||
showError: (...args: any[]) => showErrorMock(...args),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useClipboard', () => ({
|
||||
useClipboard: () => ({
|
||||
copyToClipboard: (...args: any[]) => copyToClipboardMock(...args),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('OAuthCallbackView', () => {
|
||||
beforeEach(() => {
|
||||
routeState.query = {}
|
||||
showErrorMock.mockReset()
|
||||
copyToClipboardMock.mockReset()
|
||||
})
|
||||
|
||||
it('renders localized callback copy actions', () => {
|
||||
routeState.query = {
|
||||
code: 'oauth-code',
|
||||
state: 'oauth-state',
|
||||
}
|
||||
|
||||
const wrapper = mount(OAuthCallbackView)
|
||||
|
||||
expect(wrapper.text()).toContain('auth.oauth.callbackTitle')
|
||||
expect(wrapper.text()).toContain('auth.oauth.callbackHint')
|
||||
expect(wrapper.text()).toContain('common.copy')
|
||||
expect(wrapper.find('input[value="oauth-code"]').exists()).toBe(true)
|
||||
expect(wrapper.find('input[value="oauth-state"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('sends callback errors to toast instead of rendering inline red text', () => {
|
||||
routeState.query = {
|
||||
error: 'oauth failed',
|
||||
}
|
||||
|
||||
const wrapper = mount(OAuthCallbackView)
|
||||
|
||||
expect(showErrorMock).toHaveBeenCalledWith('oauth failed')
|
||||
expect(wrapper.text()).not.toContain('oauth failed')
|
||||
expect(wrapper.find('.bg-red-50').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,9 @@ vi.mock('vue-i18n', async () => {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
if (key === 'auth.oauthFlow.totpHint') {
|
||||
return `verify ${params?.account ?? ''}`.trim()
|
||||
}
|
||||
if (!params?.providerName) {
|
||||
return key
|
||||
}
|
||||
@@ -477,6 +480,34 @@ describe('OidcCallbackView', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('shows create-account failures through toast without inline error text', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||
error: 'email_required',
|
||||
redirect: '/welcome'
|
||||
})
|
||||
apiClientPost.mockRejectedValue(new Error('create failed'))
|
||||
|
||||
const wrapper = mount(OidcCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-testid="oidc-create-account-email"]').setValue('new@example.com')
|
||||
await wrapper.get('[data-testid="oidc-create-account-password"]').setValue('secret-123')
|
||||
await wrapper.get('[data-testid="oidc-create-account-submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(showError).toHaveBeenCalledWith('create failed')
|
||||
expect(wrapper.text()).not.toContain('create failed')
|
||||
})
|
||||
|
||||
it('sends a verify code for pending oauth account creation', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||
error: 'email_required',
|
||||
|
||||
@@ -71,6 +71,9 @@ vi.mock('vue-i18n', () => ({
|
||||
}),
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
if (key === 'auth.oauthFlow.totpHint') {
|
||||
return `verify ${params?.account ?? ''}`.trim()
|
||||
}
|
||||
if (key === 'auth.oidc.callbackTitle') {
|
||||
return `Signing you in with ${params?.providerName ?? ''}`.trim()
|
||||
}
|
||||
@@ -695,6 +698,34 @@ describe('WechatCallbackView', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('shows create-account failures through toast without inline error text', async () => {
|
||||
exchangePendingOAuthCompletionMock.mockResolvedValue({
|
||||
error: 'email_required',
|
||||
redirect: '/welcome',
|
||||
})
|
||||
apiClientPostMock.mockRejectedValue(new Error('create failed'))
|
||||
|
||||
const wrapper = mount(WechatCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-testid="wechat-create-account-email"]').setValue('new@example.com')
|
||||
await wrapper.get('[data-testid="wechat-create-account-password"]').setValue('secret-123')
|
||||
await wrapper.get('[data-testid="wechat-create-account-submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(showErrorMock).toHaveBeenCalledWith('create failed')
|
||||
expect(wrapper.text()).not.toContain('create failed')
|
||||
})
|
||||
|
||||
it('sends a verify code for pending oauth account creation', async () => {
|
||||
exchangePendingOAuthCompletionMock.mockResolvedValue({
|
||||
error: 'email_required',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import WechatPaymentCallbackView from '@/views/auth/WechatPaymentCallbackView.vue'
|
||||
|
||||
const { replaceMock, routeState, locationState } = vi.hoisted(() => ({
|
||||
const { replaceMock, routeState, locationState, showErrorMock } = vi.hoisted(() => ({
|
||||
replaceMock: vi.fn(),
|
||||
routeState: {
|
||||
query: {} as Record<string, unknown>,
|
||||
@@ -16,6 +16,7 @@ const { replaceMock, routeState, locationState } = vi.hoisted(() => ({
|
||||
origin: 'http://localhost',
|
||||
} as Location & { origin: string },
|
||||
},
|
||||
showErrorMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
@@ -27,14 +28,27 @@ vi.mock('vue-router', () => ({
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
t: (key: string) => {
|
||||
if (key === 'auth.wechatPayment.callbackTitle') return '正在恢复微信支付'
|
||||
if (key === 'auth.wechatPayment.callbackProcessing') return '正在恢复微信支付...'
|
||||
if (key === 'auth.wechatPayment.backToPayment') return '返回支付页'
|
||||
if (key === 'auth.wechatPayment.callbackMissingResumeToken') return '微信支付回调缺少恢复令牌。'
|
||||
return key
|
||||
},
|
||||
locale: { value: 'zh-CN' },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useAppStore: () => ({
|
||||
showError: (...args: any[]) => showErrorMock(...args),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('WechatPaymentCallbackView', () => {
|
||||
beforeEach(() => {
|
||||
replaceMock.mockReset()
|
||||
showErrorMock.mockReset()
|
||||
routeState.query = {}
|
||||
locationState.current = {
|
||||
href: 'http://localhost/auth/wechat/payment/callback',
|
||||
@@ -72,6 +86,8 @@ describe('WechatPaymentCallbackView', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(replaceMock).not.toHaveBeenCalled()
|
||||
expect(showErrorMock).toHaveBeenCalledWith('微信支付回调缺少恢复令牌。')
|
||||
expect(wrapper.text()).toContain('微信支付回调缺少恢复令牌。')
|
||||
expect(wrapper.find('.bg-red-50').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user