mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 16:26:30 +00:00
Merge pull request #5261 from lyen1688/feat/tencent-captcha-gate
新增腾讯天御验证码认证门禁
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<TurnstileWidget
|
||||
v-if="turnstileEnabled && turnstileSiteKey"
|
||||
ref="turnstileRef"
|
||||
:site-key="turnstileSiteKey"
|
||||
@verify="(token) => emit('verify', token, '')"
|
||||
@expire="emit('expire')"
|
||||
@error="emit('error')"
|
||||
/>
|
||||
<TencentCaptchaGate
|
||||
v-else-if="tencentEnabled && tencentAppId"
|
||||
ref="tencentRef"
|
||||
:app-id="tencentAppId"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import TencentCaptchaGate from '@/components/TencentCaptchaGate.vue'
|
||||
import type { TencentCaptchaProof } from '@/utils/tencentCaptcha'
|
||||
|
||||
const props = defineProps<{
|
||||
siteKey?: string
|
||||
turnstileEnabled: boolean
|
||||
turnstileSiteKey: string
|
||||
tencentEnabled: boolean
|
||||
tencentAppId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
verify: [tokenOrTicket: string, randstr: string]
|
||||
expire: []
|
||||
error: []
|
||||
}>()
|
||||
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const tencentRef = ref<InstanceType<typeof TencentCaptchaGate> | null>(null)
|
||||
|
||||
function reset(): void {
|
||||
turnstileRef.value?.reset()
|
||||
tencentRef.value?.reset()
|
||||
}
|
||||
|
||||
async function verifyTencent(): Promise<TencentCaptchaProof | null> {
|
||||
if (!props.tencentEnabled || !props.tencentAppId) return null
|
||||
try {
|
||||
return (await tencentRef.value?.verify()) ?? null
|
||||
} catch {
|
||||
emit('error')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ reset, verifyTencent })
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<span v-if="false" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
loadTencentCaptcha,
|
||||
type TencentCaptchaProof,
|
||||
type TencentCaptchaResult
|
||||
} from '@/utils/tencentCaptcha'
|
||||
|
||||
const { locale } = useI18n()
|
||||
const props = defineProps<{ appId: string }>()
|
||||
|
||||
let instance: { show(): void; destroy(): void } | null = null
|
||||
let pending: Promise<TencentCaptchaProof | null> | null = null
|
||||
let cancelPending: (() => void) | null = null
|
||||
|
||||
function createVerificationPromise(): Promise<TencentCaptchaProof | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
|
||||
const finish = (callback: () => void): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (cancelPending === cancel) cancelPending = null
|
||||
instance?.destroy()
|
||||
instance = null
|
||||
callback()
|
||||
}
|
||||
const cancel = (): void => finish(() => resolve(null))
|
||||
|
||||
cancelPending = cancel
|
||||
void loadTencentCaptcha()
|
||||
.then((TencentCaptcha) => {
|
||||
if (cancelPending !== cancel) return
|
||||
|
||||
const userLanguage = locale.value.toLowerCase().startsWith('zh') ? 'zh-cn' : 'en'
|
||||
instance = new TencentCaptcha(props.appId, (result: TencentCaptchaResult) => {
|
||||
if (result.ret === 2) {
|
||||
finish(() => resolve(null))
|
||||
return
|
||||
}
|
||||
|
||||
const ticket = result.ticket?.trim() || ''
|
||||
const randstr = result.randstr?.trim() || ''
|
||||
if (!ticket || !randstr || ticket.startsWith('trerror_') || result.errorCode !== undefined) {
|
||||
finish(() => reject(new Error('Tencent Captcha verification failed')))
|
||||
return
|
||||
}
|
||||
|
||||
finish(() => resolve({ ticket, randstr }))
|
||||
}, { userLanguage })
|
||||
instance.show()
|
||||
})
|
||||
.catch((error: unknown) => finish(() => reject(error)))
|
||||
})
|
||||
}
|
||||
|
||||
function verify(): Promise<TencentCaptchaProof | null> {
|
||||
if (pending) return pending
|
||||
pending = createVerificationPromise().finally(() => {
|
||||
pending = null
|
||||
})
|
||||
return pending
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
instance?.destroy()
|
||||
instance = null
|
||||
cancelPending?.()
|
||||
cancelPending = null
|
||||
}
|
||||
|
||||
onBeforeUnmount(reset)
|
||||
defineExpose({ verify, reset })
|
||||
</script>
|
||||
@@ -0,0 +1,131 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import TencentCaptchaGate from '@/components/TencentCaptchaGate.vue'
|
||||
import { resetTencentCaptchaLoaderForTest } from '@/utils/tencentCaptcha'
|
||||
|
||||
const locale = { value: 'zh' }
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ locale })
|
||||
}))
|
||||
|
||||
type CaptchaResult = {
|
||||
ret: number
|
||||
ticket?: string | null
|
||||
randstr?: string | null
|
||||
errorCode?: number
|
||||
}
|
||||
|
||||
describe('TencentCaptchaGate', () => {
|
||||
beforeEach(() => {
|
||||
locale.value = 'zh'
|
||||
delete window.TencentCaptcha
|
||||
document.head.querySelectorAll('script[src*="TJCaptcha.js"]').forEach((node) => node.remove())
|
||||
resetTencentCaptchaLoaderForTest()
|
||||
})
|
||||
|
||||
it('does not render a visible verification button', () => {
|
||||
const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
|
||||
|
||||
expect(wrapper.find('button').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves proof after Tencent SDK success', async () => {
|
||||
let callback: ((result: CaptchaResult) => void) | undefined
|
||||
window.TencentCaptcha = class {
|
||||
constructor(_appId: string, resultCallback: (result: CaptchaResult) => void) {
|
||||
callback = resultCallback
|
||||
}
|
||||
show = vi.fn()
|
||||
destroy = vi.fn()
|
||||
}
|
||||
const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
|
||||
|
||||
const verification = wrapper.vm.verify()
|
||||
await flushPromises()
|
||||
callback?.({ ret: 0, ticket: 'ticket-value', randstr: 'rand-value' })
|
||||
|
||||
await expect(verification).resolves.toEqual({ ticket: 'ticket-value', randstr: 'rand-value' })
|
||||
})
|
||||
|
||||
it('resolves null when the user closes the popup', async () => {
|
||||
let callback: ((result: CaptchaResult) => void) | undefined
|
||||
window.TencentCaptcha = class {
|
||||
constructor(_appId: string, resultCallback: (result: CaptchaResult) => void) {
|
||||
callback = resultCallback
|
||||
}
|
||||
show = vi.fn()
|
||||
destroy = vi.fn()
|
||||
}
|
||||
const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
|
||||
|
||||
const verification = wrapper.vm.verify()
|
||||
await flushPromises()
|
||||
callback?.({ ret: 2, ticket: null })
|
||||
|
||||
await expect(verification).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('rejects SDK load failures and disaster-recovery tickets', async () => {
|
||||
const failedLoad = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
|
||||
const loadVerification = failedLoad.vm.verify()
|
||||
const script = document.head.querySelector<HTMLScriptElement>('script[src*="TJCaptcha.js"]')
|
||||
expect(script).not.toBeNull()
|
||||
script?.dispatchEvent(new Event('error'))
|
||||
await expect(loadVerification).rejects.toThrow('Failed to load Tencent Captcha SDK')
|
||||
|
||||
let callback: ((result: CaptchaResult) => void) | undefined
|
||||
window.TencentCaptcha = class {
|
||||
constructor(_appId: string, resultCallback: (result: CaptchaResult) => void) {
|
||||
callback = resultCallback
|
||||
}
|
||||
show = vi.fn()
|
||||
destroy = vi.fn()
|
||||
}
|
||||
const failedResult = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
|
||||
const resultVerification = failedResult.vm.verify()
|
||||
await flushPromises()
|
||||
callback?.({ ret: 0, ticket: 'trerror_1001_123456789', randstr: '@fallback', errorCode: 1001 })
|
||||
|
||||
await expect(resultVerification).rejects.toThrow('Tencent Captcha verification failed')
|
||||
})
|
||||
|
||||
it('reuses one pending promise for concurrent verify calls', async () => {
|
||||
const show = vi.fn()
|
||||
let callback: ((result: CaptchaResult) => void) | undefined
|
||||
window.TencentCaptcha = class {
|
||||
constructor(_appId: string, resultCallback: (result: CaptchaResult) => void) {
|
||||
callback = resultCallback
|
||||
}
|
||||
show = show
|
||||
destroy = vi.fn()
|
||||
}
|
||||
const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
|
||||
|
||||
const first = wrapper.vm.verify()
|
||||
const second = wrapper.vm.verify()
|
||||
await flushPromises()
|
||||
callback?.({ ret: 0, ticket: 'ticket-value', randstr: 'rand-value' })
|
||||
|
||||
await expect(first).resolves.toEqual({ ticket: 'ticket-value', randstr: 'rand-value' })
|
||||
await expect(second).resolves.toEqual({ ticket: 'ticket-value', randstr: 'rand-value' })
|
||||
expect(show).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('settles a pending verification when reset', async () => {
|
||||
const destroy = vi.fn()
|
||||
window.TencentCaptcha = class {
|
||||
constructor(_appId: string, _callback: (result: CaptchaResult) => void) {}
|
||||
show = vi.fn()
|
||||
destroy = destroy
|
||||
}
|
||||
const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
|
||||
|
||||
const verification = wrapper.vm.verify()
|
||||
await flushPromises()
|
||||
wrapper.vm.reset()
|
||||
|
||||
await expect(verification).resolves.toBeNull()
|
||||
expect(destroy).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -37,6 +37,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { OAuthLoginStart } from '@/api/auth'
|
||||
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -46,6 +47,9 @@ const props = withDefaults(defineProps<{
|
||||
}>(), {
|
||||
showDivider: true
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
start: [request: OAuthLoginStart]
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
@@ -53,9 +57,6 @@ const { t } = useI18n()
|
||||
function startLogin(): void {
|
||||
const redirectTo = (route.query.redirect as string) || '/dashboard'
|
||||
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
|
||||
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
|
||||
const normalized = apiBase.replace(/\/$/, '')
|
||||
const startURL = `${normalized}/auth/oauth/dingtalk/start?redirect=${encodeURIComponent(redirectTo)}`
|
||||
window.location.href = startURL
|
||||
emit('start', { provider: 'dingtalk', params: { redirect: redirectTo } })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -31,6 +31,7 @@ import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import GitHubMark from './GitHubMark.vue'
|
||||
import GoogleMark from './GoogleMark.vue'
|
||||
import type { OAuthLoginStart } from '@/api/auth'
|
||||
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
|
||||
|
||||
type EmailOAuthProvider = 'github' | 'google'
|
||||
@@ -45,6 +46,9 @@ const props = withDefaults(defineProps<{
|
||||
}>(), {
|
||||
showDivider: true
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
start: [request: OAuthLoginStart]
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
@@ -75,13 +79,10 @@ function startLogin(provider: EmailOAuthProvider): void {
|
||||
const affiliateCode = resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code)
|
||||
storeOAuthAffiliateCode(affiliateCode)
|
||||
window.sessionStorage.setItem(EMAIL_OAUTH_PENDING_PROVIDER_KEY, provider)
|
||||
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
|
||||
const normalized = apiBase.replace(/\/$/, '')
|
||||
const params = new URLSearchParams({ redirect: redirectTo })
|
||||
const params: Record<string, string> = { redirect: redirectTo }
|
||||
if (affiliateCode) {
|
||||
params.set('aff_code', affiliateCode)
|
||||
params.aff_code = affiliateCode
|
||||
}
|
||||
const startURL = `${normalized}/auth/oauth/${provider}/start?${params.toString()}`
|
||||
window.location.href = startURL
|
||||
emit('start', { provider, params })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { OAuthLoginStart } from '@/api/auth'
|
||||
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -51,6 +52,9 @@ const props = withDefaults(defineProps<{
|
||||
}>(), {
|
||||
showDivider: true
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
start: [request: OAuthLoginStart]
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
@@ -58,9 +62,6 @@ const { t } = useI18n()
|
||||
function startLogin(): void {
|
||||
const redirectTo = (route.query.redirect as string) || '/dashboard'
|
||||
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
|
||||
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
|
||||
const normalized = apiBase.replace(/\/$/, '')
|
||||
const startURL = `${normalized}/auth/oauth/linuxdo/start?redirect=${encodeURIComponent(redirectTo)}`
|
||||
window.location.href = startURL
|
||||
emit('start', { provider: 'linuxdo', params: { redirect: redirectTo } })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { OAuthLoginStart } from '@/api/auth'
|
||||
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -34,6 +35,9 @@ const props = withDefaults(defineProps<{
|
||||
providerName: 'OIDC',
|
||||
showDivider: true
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
start: [request: OAuthLoginStart]
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
@@ -48,9 +52,6 @@ const providerInitial = computed(() => normalizedProviderName.value.charAt(0).to
|
||||
function startLogin(): void {
|
||||
const redirectTo = (route.query.redirect as string) || '/dashboard'
|
||||
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
|
||||
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
|
||||
const normalized = apiBase.replace(/\/$/, '')
|
||||
const startURL = `${normalized}/auth/oauth/oidc/start?redirect=${encodeURIComponent(redirectTo)}`
|
||||
window.location.href = startURL
|
||||
emit('start', { provider: 'oidc', params: { redirect: redirectTo } })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -16,10 +16,14 @@
|
||||
:placeholder="t('auth.passwordPlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
/>
|
||||
<div v-if="emailVerifyEnabled && turnstileEnabled && turnstileSiteKey" class="space-y-2">
|
||||
<div v-if="captchaEnabled" class="space-y-2">
|
||||
<TurnstileWidget
|
||||
ref="turnstileRef"
|
||||
:site-key="turnstileSiteKey"
|
||||
:turnstile-enabled="turnstileEnabled"
|
||||
:turnstile-site-key="turnstileSiteKey"
|
||||
:tencent-enabled="tencentCaptchaEnabled"
|
||||
:tencent-app-id="tencentCaptchaAppId"
|
||||
@verify="onTurnstileVerify"
|
||||
@expire="onTurnstileExpire"
|
||||
@error="onTurnstileError"
|
||||
@@ -88,9 +92,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import TurnstileWidget from '@/components/CaptchaChallenge.vue'
|
||||
import { getPublicSettings, sendPendingOAuthVerifyCode } from '@/api/auth'
|
||||
import { useAppStore } from '@/stores'
|
||||
|
||||
@@ -98,6 +102,9 @@ export type PendingOAuthCreateAccountPayload = {
|
||||
email: string
|
||||
password: string
|
||||
verifyCode: string
|
||||
turnstileToken?: string
|
||||
tencentCaptchaTicket?: string
|
||||
tencentCaptchaRandstr?: string
|
||||
invitationCode?: string
|
||||
}
|
||||
|
||||
@@ -128,8 +135,16 @@ const invitationCodeEnabled = ref(false)
|
||||
const emailVerifyEnabled = ref(true)
|
||||
const turnstileEnabled = ref(false)
|
||||
const turnstileSiteKey = ref('')
|
||||
const tencentCaptchaEnabled = ref(false)
|
||||
const tencentCaptchaAppId = ref('')
|
||||
const turnstileToken = ref('')
|
||||
const tencentCaptchaRandstr = ref('')
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const captchaEnabled = computed(
|
||||
() =>
|
||||
(turnstileEnabled.value && Boolean(turnstileSiteKey.value)) ||
|
||||
(tencentCaptchaEnabled.value && Boolean(tencentCaptchaAppId.value))
|
||||
)
|
||||
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
@@ -152,6 +167,9 @@ watch(
|
||||
value => {
|
||||
if (value) {
|
||||
appStore.showError(value)
|
||||
if (captchaEnabled.value) {
|
||||
resetTurnstile()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -189,24 +207,39 @@ function getRequestErrorMessage(error: unknown, fallback: string): string {
|
||||
|
||||
function resetTurnstile() {
|
||||
turnstileToken.value = ''
|
||||
tencentCaptchaRandstr.value = ''
|
||||
turnstileRef.value?.reset()
|
||||
}
|
||||
|
||||
function onTurnstileVerify(token: string) {
|
||||
function onTurnstileVerify(token: string, randstr = '') {
|
||||
turnstileToken.value = token
|
||||
tencentCaptchaRandstr.value = randstr
|
||||
sendCodeError.value = ''
|
||||
}
|
||||
|
||||
function onTurnstileExpire() {
|
||||
turnstileToken.value = ''
|
||||
tencentCaptchaRandstr.value = ''
|
||||
sendCodeError.value = t('auth.turnstileExpired')
|
||||
}
|
||||
|
||||
function onTurnstileError() {
|
||||
turnstileToken.value = ''
|
||||
tencentCaptchaRandstr.value = ''
|
||||
sendCodeError.value = t('auth.turnstileFailed')
|
||||
}
|
||||
|
||||
async function acquireTencentProof(): Promise<boolean> {
|
||||
if (!tencentCaptchaEnabled.value) return true
|
||||
|
||||
const proof = await turnstileRef.value?.verifyTencent()
|
||||
if (!proof) return false
|
||||
|
||||
turnstileToken.value = proof.ticket
|
||||
tencentCaptchaRandstr.value = proof.randstr
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleSendCode() {
|
||||
const trimmedEmail = email.value.trim()
|
||||
if (!trimmedEmail) {
|
||||
@@ -218,6 +251,10 @@ async function handleSendCode() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await acquireTencentProof())) {
|
||||
return
|
||||
}
|
||||
|
||||
isSendingCode.value = true
|
||||
sendCodeError.value = ''
|
||||
sendCodeSuccess.value = false
|
||||
@@ -225,32 +262,49 @@ async function handleSendCode() {
|
||||
try {
|
||||
const response = await sendPendingOAuthVerifyCode({
|
||||
email: trimmedEmail,
|
||||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined
|
||||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
|
||||
tencent_captcha_ticket: tencentCaptchaEnabled.value ? turnstileToken.value : undefined,
|
||||
tencent_captcha_randstr: tencentCaptchaEnabled.value ? tencentCaptchaRandstr.value : undefined
|
||||
})
|
||||
sendCodeSuccess.value = true
|
||||
startCountdown(response.countdown)
|
||||
if (turnstileEnabled.value) {
|
||||
resetTurnstile()
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
sendCodeError.value = getRequestErrorMessage(error, t('auth.sendCodeFailed'))
|
||||
} finally {
|
||||
if (captchaEnabled.value) {
|
||||
resetTurnstile()
|
||||
}
|
||||
isSendingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
async function handleSubmit() {
|
||||
const trimmedEmail = email.value.trim()
|
||||
if (!trimmedEmail || password.value.length < 6) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await acquireTencentProof())) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('submit', {
|
||||
email: trimmedEmail,
|
||||
password: password.value,
|
||||
verifyCode: emailVerifyEnabled.value ? verifyCode.value.trim() : '',
|
||||
...(turnstileEnabled.value && turnstileToken.value ? { turnstileToken: turnstileToken.value } : {}),
|
||||
...(tencentCaptchaEnabled.value && turnstileToken.value
|
||||
? {
|
||||
tencentCaptchaTicket: turnstileToken.value,
|
||||
tencentCaptchaRandstr: tencentCaptchaRandstr.value
|
||||
}
|
||||
: {}),
|
||||
invitationCode: invitationCode.value.trim() || undefined
|
||||
})
|
||||
|
||||
if (tencentCaptchaEnabled.value) {
|
||||
resetTurnstile()
|
||||
}
|
||||
}
|
||||
|
||||
function emitSwitchToBind() {
|
||||
@@ -264,11 +318,15 @@ onMounted(async () => {
|
||||
emailVerifyEnabled.value = settings.email_verify_enabled !== false
|
||||
turnstileEnabled.value = settings.turnstile_enabled === true
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
|
||||
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
|
||||
} catch {
|
||||
invitationCodeEnabled.value = false
|
||||
emailVerifyEnabled.value = true
|
||||
turnstileEnabled.value = false
|
||||
turnstileSiteKey.value = ''
|
||||
tencentCaptchaEnabled.value = false
|
||||
tencentCaptchaAppId.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { resolveWeChatOAuthStart } from '@/api/auth'
|
||||
import { resolveWeChatOAuthStart, type OAuthLoginStart } from '@/api/auth'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
|
||||
|
||||
@@ -42,6 +42,9 @@ const props = withDefaults(defineProps<{
|
||||
}>(), {
|
||||
showDivider: true,
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
start: [request: OAuthLoginStart]
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
const route = useRoute()
|
||||
@@ -87,10 +90,10 @@ function startLogin(): void {
|
||||
}
|
||||
const redirectTo = (route.query.redirect as string) || '/dashboard'
|
||||
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
|
||||
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
|
||||
const normalized = apiBase.replace(/\/$/, '')
|
||||
const mode = resolvedStart.value.mode
|
||||
const startURL = `${normalized}/auth/oauth/wechat/start?mode=${mode}&redirect=${encodeURIComponent(redirectTo)}`
|
||||
window.location.href = startURL
|
||||
emit('start', {
|
||||
provider: 'wechat',
|
||||
params: { mode, redirect: redirectTo }
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -6,10 +6,6 @@ const routeState = vi.hoisted(() => ({
|
||||
query: {} as Record<string, unknown>,
|
||||
}))
|
||||
|
||||
const locationState = vi.hoisted(() => ({
|
||||
current: { href: 'http://localhost/register?aff=AFF123' } as { href: string },
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => routeState,
|
||||
}))
|
||||
@@ -28,16 +24,11 @@ vi.mock('vue-i18n', () => ({
|
||||
describe('EmailOAuthButtons', () => {
|
||||
beforeEach(() => {
|
||||
routeState.query = { redirect: '/billing?plan=pro', aff: 'AFF123' }
|
||||
locationState.current = { href: 'http://localhost/register?aff=AFF123' }
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: locationState.current,
|
||||
})
|
||||
window.localStorage.clear()
|
||||
window.sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('passes the affiliate code to the email oauth start URL', async () => {
|
||||
it('emits the GitHub OAuth request with redirect and affiliate parameters', async () => {
|
||||
const wrapper = mount(EmailOAuthButtons, {
|
||||
props: {
|
||||
githubEnabled: true,
|
||||
@@ -53,13 +44,40 @@ describe('EmailOAuthButtons', () => {
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(locationState.current.href).toBe(
|
||||
'/api/v1/auth/oauth/github/start?redirect=%2Fbilling%3Fplan%3Dpro&aff_code=AFF123'
|
||||
)
|
||||
expect(wrapper.emitted('start')).toEqual([[
|
||||
{
|
||||
provider: 'github',
|
||||
params: { redirect: '/billing?plan=pro', aff_code: 'AFF123' }
|
||||
}
|
||||
]])
|
||||
expect(window.sessionStorage.getItem('oauth_aff_code')).toBe('AFF123')
|
||||
expect(window.sessionStorage.getItem('email_oauth_pending_provider')).toBe('github')
|
||||
})
|
||||
|
||||
it('emits the Google provider without navigating directly', async () => {
|
||||
const originalHref = window.location.href
|
||||
const wrapper = mount(EmailOAuthButtons, {
|
||||
props: {
|
||||
githubEnabled: false,
|
||||
googleEnabled: true,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
GitHubMark: true,
|
||||
GoogleMark: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('start')?.[0]?.[0]).toEqual({
|
||||
provider: 'google',
|
||||
params: { redirect: '/billing?plan=pro', aff_code: 'AFF123' }
|
||||
})
|
||||
expect(window.location.href).toBe(originalHref)
|
||||
})
|
||||
|
||||
it('uses a full-width descriptive button when only GitHub is enabled', () => {
|
||||
const wrapper = mount(EmailOAuthButtons, {
|
||||
props: {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
||||
import DingTalkOAuthSection from '@/components/auth/DingTalkOAuthSection.vue'
|
||||
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
|
||||
|
||||
const routeState = vi.hoisted(() => ({
|
||||
query: {} as Record<string, unknown>
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => routeState
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
describe('OAuth login sections', () => {
|
||||
beforeEach(() => {
|
||||
routeState.query = { redirect: '/billing?plan=pro', aff: 'AFF123' }
|
||||
window.sessionStorage.clear()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['linuxdo', LinuxDoOAuthSection],
|
||||
['dingtalk', DingTalkOAuthSection],
|
||||
['oidc', OidcOAuthSection]
|
||||
] as const)('emits a %s start request from the original button', async (provider, component) => {
|
||||
const originalHref = window.location.href
|
||||
const wrapper = mount(component, { props: { affCode: 'AFF456' } })
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('start')?.[0]?.[0]).toEqual({
|
||||
provider,
|
||||
params: { redirect: '/billing?plan=pro' }
|
||||
})
|
||||
expect(window.sessionStorage.getItem('oauth_aff_code')).toBe('AFF456')
|
||||
expect(window.location.href).toBe(originalHref)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
@@ -7,6 +8,8 @@ const sendVerifyCode = vi.fn()
|
||||
const sendPendingOAuthVerifyCode = vi.fn()
|
||||
const getPublicSettings = vi.fn()
|
||||
const showError = vi.fn()
|
||||
const turnstileReset = vi.fn()
|
||||
const verifyTencent = vi.fn()
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
@@ -40,12 +43,69 @@ describe('PendingOAuthCreateAccountForm', () => {
|
||||
sendPendingOAuthVerifyCode.mockReset()
|
||||
getPublicSettings.mockReset()
|
||||
showError.mockReset()
|
||||
turnstileReset.mockReset()
|
||||
verifyTencent.mockReset()
|
||||
getPublicSettings.mockResolvedValue({
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('acquires separate proofs for pending OAuth send-code and create-account', async () => {
|
||||
getPublicSettings.mockResolvedValue({
|
||||
email_verify_enabled: true,
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: '',
|
||||
tencent_captcha_enabled: true,
|
||||
tencent_captcha_app_id: 'tencent-app-id'
|
||||
})
|
||||
sendPendingOAuthVerifyCode.mockResolvedValue({ countdown: 0 })
|
||||
verifyTencent
|
||||
.mockResolvedValueOnce({ ticket: 'ticket-1', randstr: '@rand-1' })
|
||||
.mockResolvedValueOnce({ ticket: 'ticket-2', randstr: '@rand-2' })
|
||||
const CaptchaChallengeStub = defineComponent({
|
||||
setup(_, { expose }) {
|
||||
expose({ verifyTencent, reset: turnstileReset })
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = mount(PendingOAuthCreateAccountForm, {
|
||||
props: {
|
||||
testIdPrefix: 'oidc',
|
||||
initialEmail: 'user@example.com',
|
||||
isSubmitting: false
|
||||
},
|
||||
global: {
|
||||
stubs: { TurnstileWidget: CaptchaChallengeStub }
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-testid="oidc-create-account-password"]').setValue('secret-123')
|
||||
await wrapper.get('[data-testid="oidc-create-account-verify-code"]').setValue('246810')
|
||||
await wrapper.get('[data-testid="oidc-create-account-send-code"]').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-testid="oidc-create-account-submit"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(verifyTencent).toHaveBeenCalledTimes(2)
|
||||
expect(sendPendingOAuthVerifyCode).toHaveBeenCalledWith({
|
||||
email: 'user@example.com',
|
||||
tencent_captcha_ticket: 'ticket-1',
|
||||
tencent_captcha_randstr: '@rand-1'
|
||||
})
|
||||
expect(wrapper.emitted('submit')).toEqual([
|
||||
[
|
||||
expect.objectContaining({
|
||||
tencentCaptchaTicket: 'ticket-2',
|
||||
tencentCaptchaRandstr: '@rand-2'
|
||||
})
|
||||
]
|
||||
])
|
||||
expect(turnstileReset).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('emits trimmed email, password, and verify code on submit', async () => {
|
||||
const wrapper = mount(PendingOAuthCreateAccountForm, {
|
||||
props: {
|
||||
@@ -195,6 +255,38 @@ describe('PendingOAuthCreateAccountForm', () => {
|
||||
expect(wrapper.text()).not.toContain('send failed')
|
||||
})
|
||||
|
||||
it('consumes the captcha proof when sending a verify code fails', async () => {
|
||||
getPublicSettings.mockResolvedValue({
|
||||
turnstile_enabled: true,
|
||||
turnstile_site_key: 'site-key'
|
||||
})
|
||||
sendPendingOAuthVerifyCode.mockRejectedValue(new Error('send failed'))
|
||||
|
||||
const wrapper = mount(PendingOAuthCreateAccountForm, {
|
||||
props: {
|
||||
testIdPrefix: 'oidc',
|
||||
initialEmail: 'user@example.com',
|
||||
isSubmitting: false
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
TurnstileWidget: {
|
||||
template: '<button data-testid="turnstile-verify" @click="$emit(\'verify\', \'proof-token\')">verify</button>',
|
||||
methods: { reset: turnstileReset }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-testid="turnstile-verify"]').trigger('click')
|
||||
await wrapper.get('[data-testid="oidc-create-account-send-code"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(turnstileReset).toHaveBeenCalledOnce()
|
||||
expect(wrapper.get('[data-testid="oidc-create-account-send-code"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('requires a turnstile token before sending a verify code when turnstile is enabled', async () => {
|
||||
getPublicSettings.mockResolvedValue({
|
||||
turnstile_enabled: true,
|
||||
@@ -215,7 +307,8 @@ describe('PendingOAuthCreateAccountForm', () => {
|
||||
global: {
|
||||
stubs: {
|
||||
TurnstileWidget: {
|
||||
template: '<button data-testid="turnstile-verify" @click="$emit(\'verify\', \'turnstile-token\')">verify</button>'
|
||||
template: '<button data-testid="turnstile-verify" @click="$emit(\'verify\', \'turnstile-token\')">verify</button>',
|
||||
methods: { reset: vi.fn() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,6 @@ const routeState = vi.hoisted(() => ({
|
||||
query: {} as Record<string, unknown>,
|
||||
}))
|
||||
|
||||
const locationState = vi.hoisted(() => ({
|
||||
current: { href: 'http://localhost/login' } as { href: string },
|
||||
}))
|
||||
|
||||
let pinia: ReturnType<typeof createPinia>
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
@@ -105,11 +101,6 @@ describe('WechatOAuthSection', () => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
routeState.query = { redirect: '/billing?plan=pro' }
|
||||
locationState.current = { href: 'http://localhost/login' }
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: locationState.current,
|
||||
})
|
||||
Object.defineProperty(window.navigator, 'userAgent', {
|
||||
configurable: true,
|
||||
value: 'Mozilla/5.0',
|
||||
@@ -135,9 +126,10 @@ describe('WechatOAuthSection', () => {
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(locationState.current.href).toContain(
|
||||
'/api/v1/auth/oauth/wechat/start?mode=open&redirect=%2Fbilling%3Fplan%3Dpro'
|
||||
)
|
||||
expect(wrapper.emitted('start')?.[0]?.[0]).toEqual({
|
||||
provider: 'wechat',
|
||||
params: { mode: 'open', redirect: '/billing?plan=pro' }
|
||||
})
|
||||
})
|
||||
|
||||
it('uses mp mode inside the WeChat browser when mp mode is configured', async () => {
|
||||
@@ -157,9 +149,10 @@ describe('WechatOAuthSection', () => {
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(locationState.current.href).toContain(
|
||||
'/api/v1/auth/oauth/wechat/start?mode=mp&redirect=%2Fbilling%3Fplan%3Dpro'
|
||||
)
|
||||
expect(wrapper.emitted('start')?.[0]?.[0]).toEqual({
|
||||
provider: 'wechat',
|
||||
params: { mode: 'mp', redirect: '/billing?plan=pro' }
|
||||
})
|
||||
})
|
||||
|
||||
it('disables the button outside the WeChat browser when only mp mode is configured', async () => {
|
||||
@@ -178,7 +171,7 @@ describe('WechatOAuthSection', () => {
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(locationState.current.href).toBe('http://localhost/login')
|
||||
expect(wrapper.emitted('start')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('disables the button inside the WeChat browser when only open mode is configured', async () => {
|
||||
@@ -201,7 +194,7 @@ describe('WechatOAuthSection', () => {
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(locationState.current.href).toBe('http://localhost/login')
|
||||
expect(wrapper.emitted('start')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the legacy overall enabled flag when per-mode settings are not present', async () => {
|
||||
@@ -216,9 +209,10 @@ describe('WechatOAuthSection', () => {
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(locationState.current.href).toContain(
|
||||
'/api/v1/auth/oauth/wechat/start?mode=open&redirect=%2Fbilling%3Fplan%3Dpro'
|
||||
)
|
||||
expect(wrapper.emitted('start')?.[0]?.[0]).toEqual({
|
||||
provider: 'wechat',
|
||||
params: { mode: 'open', redirect: '/billing?plan=pro' }
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the localized not-configured hint when WeChat OAuth is unavailable', async () => {
|
||||
|
||||
Reference in New Issue
Block a user