feat: rebuild auth identity foundation flow
This commit is contained in:
@@ -11,32 +11,94 @@
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="needsInvitation" class="space-y-4">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('auth.linuxdo.invitationRequired') }}
|
||||
</p>
|
||||
<div>
|
||||
<input
|
||||
v-model="invitationCode"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
:placeholder="t('auth.invitationCodePlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
@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()"
|
||||
@click="handleSubmitInvitation"
|
||||
<div v-if="needsInvitation || needsAdoptionConfirmation" class="space-y-4">
|
||||
<div
|
||||
v-if="adoptionRequired && (suggestedDisplayName || suggestedAvatarUrl)"
|
||||
class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60"
|
||||
>
|
||||
{{ isSubmitting ? t('auth.linuxdo.completing') : t('auth.linuxdo.completeRegistration') }}
|
||||
</button>
|
||||
<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
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label
|
||||
v-if="suggestedDisplayName"
|
||||
class="flex items-start gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-dark-600 dark:bg-dark-900/50"
|
||||
>
|
||||
<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
|
||||
</span>
|
||||
<span class="block text-gray-500 dark:text-dark-400">
|
||||
{{ suggestedDisplayName }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label
|
||||
v-if="suggestedAvatarUrl"
|
||||
class="flex items-start gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-dark-600 dark:bg-dark-900/50"
|
||||
>
|
||||
<input v-model="adoptAvatar" type="checkbox" class="mt-1 h-4 w-4" />
|
||||
<img
|
||||
:src="suggestedAvatarUrl"
|
||||
alt="LinuxDo avatar"
|
||||
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
|
||||
</span>
|
||||
<span class="block break-all text-gray-500 dark:text-dark-400">
|
||||
{{ suggestedAvatarUrl }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="needsInvitation">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('auth.linuxdo.invitationRequired') }}
|
||||
</p>
|
||||
<div>
|
||||
<input
|
||||
v-model="invitationCode"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
:placeholder="t('auth.invitationCodePlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
@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()"
|
||||
@click="handleSubmitInvitation"
|
||||
>
|
||||
{{ isSubmitting ? t('auth.linuxdo.completing') : t('auth.linuxdo.completeRegistration') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="needsAdoptionConfirmation">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Review the LinuxDo profile details before continuing.
|
||||
</p>
|
||||
<button class="btn btn-primary w-full" :disabled="isSubmitting" @click="handleContinueLogin">
|
||||
{{ isSubmitting ? t('common.processing') : 'Continue' }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
@@ -71,7 +133,12 @@ import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import { completeLinuxDoOAuthRegistration } from '@/api/auth'
|
||||
import {
|
||||
completeLinuxDoOAuthRegistration,
|
||||
exchangePendingOAuthCompletion,
|
||||
type OAuthAdoptionDecision,
|
||||
type PendingOAuthExchangeResponse
|
||||
} from '@/api/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -85,11 +152,16 @@ const errorMessage = ref('')
|
||||
|
||||
// Invitation code flow state
|
||||
const needsInvitation = ref(false)
|
||||
const pendingOAuthToken = ref('')
|
||||
const invitationCode = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
const invitationError = ref('')
|
||||
const redirectTo = ref('/dashboard')
|
||||
const adoptionRequired = ref(false)
|
||||
const suggestedDisplayName = ref('')
|
||||
const suggestedAvatarUrl = ref('')
|
||||
const adoptDisplayName = ref(true)
|
||||
const adoptAvatar = ref(true)
|
||||
const needsAdoptionConfirmation = ref(false)
|
||||
|
||||
function parseFragmentParams(): URLSearchParams {
|
||||
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
||||
@@ -106,6 +178,54 @@ function sanitizeRedirectPath(path: string | null | undefined): string {
|
||||
return path
|
||||
}
|
||||
|
||||
function currentAdoptionDecision(): OAuthAdoptionDecision {
|
||||
return {
|
||||
adoptDisplayName: adoptDisplayName.value,
|
||||
adoptAvatar: adoptAvatar.value
|
||||
}
|
||||
}
|
||||
|
||||
function applyAdoptionSuggestionState(completion: {
|
||||
adoption_required?: boolean
|
||||
suggested_display_name?: string
|
||||
suggested_avatar_url?: string
|
||||
}) {
|
||||
adoptionRequired.value = completion.adoption_required === true
|
||||
suggestedDisplayName.value = completion.suggested_display_name || ''
|
||||
suggestedAvatarUrl.value = completion.suggested_avatar_url || ''
|
||||
|
||||
if (!suggestedDisplayName.value) {
|
||||
adoptDisplayName.value = false
|
||||
}
|
||||
if (!suggestedAvatarUrl.value) {
|
||||
adoptAvatar.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function hasSuggestedProfile(completion: {
|
||||
suggested_display_name?: string
|
||||
suggested_avatar_url?: string
|
||||
}): boolean {
|
||||
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
|
||||
}
|
||||
|
||||
async function finalizeLogin(completion: PendingOAuthExchangeResponse, redirect: string) {
|
||||
if (!completion.access_token) {
|
||||
throw new Error(t('auth.linuxdo.callbackMissingToken'))
|
||||
}
|
||||
|
||||
if (completion.refresh_token) {
|
||||
localStorage.setItem('refresh_token', completion.refresh_token)
|
||||
}
|
||||
if (completion.expires_in) {
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + completion.expires_in * 1000))
|
||||
}
|
||||
|
||||
await authStore.setToken(completion.access_token)
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirect)
|
||||
}
|
||||
|
||||
async function handleSubmitInvitation() {
|
||||
invitationError.value = ''
|
||||
if (!invitationCode.value.trim()) return
|
||||
@@ -113,8 +233,8 @@ async function handleSubmitInvitation() {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const tokenData = await completeLinuxDoOAuthRegistration(
|
||||
pendingOAuthToken.value,
|
||||
invitationCode.value.trim()
|
||||
invitationCode.value.trim(),
|
||||
currentAdoptionDecision()
|
||||
)
|
||||
if (tokenData.refresh_token) {
|
||||
localStorage.setItem('refresh_token', tokenData.refresh_token)
|
||||
@@ -134,63 +254,65 @@ async function handleSubmitInvitation() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleContinueLogin() {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const completion = await exchangePendingOAuthCompletion(currentAdoptionDecision())
|
||||
await finalizeLogin(completion, redirectTo.value)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||
errorMessage.value =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
t('auth.loginFailed')
|
||||
appStore.showError(errorMessage.value)
|
||||
needsAdoptionConfirmation.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const params = parseFragmentParams()
|
||||
|
||||
const token = params.get('access_token') || ''
|
||||
const refreshToken = params.get('refresh_token') || ''
|
||||
const expiresInStr = params.get('expires_in') || ''
|
||||
const redirect = sanitizeRedirectPath(
|
||||
params.get('redirect') || (route.query.redirect as string | undefined) || '/dashboard'
|
||||
)
|
||||
const error = params.get('error')
|
||||
const errorDesc = params.get('error_description') || params.get('error_message') || ''
|
||||
|
||||
if (error) {
|
||||
if (error === 'invitation_required') {
|
||||
pendingOAuthToken.value = params.get('pending_oauth_token') || ''
|
||||
redirectTo.value = sanitizeRedirectPath(params.get('redirect'))
|
||||
if (!pendingOAuthToken.value) {
|
||||
errorMessage.value = t('auth.linuxdo.invalidPendingToken')
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
needsInvitation.value = true
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
errorMessage.value = errorDesc || error
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
errorMessage.value = t('auth.linuxdo.callbackMissingToken')
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Store refresh token and expires_at (convert to timestamp) if provided
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('refresh_token', refreshToken)
|
||||
}
|
||||
if (expiresInStr) {
|
||||
const expiresIn = parseInt(expiresInStr, 10)
|
||||
if (!isNaN(expiresIn)) {
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + expiresIn * 1000))
|
||||
}
|
||||
const completion = await exchangePendingOAuthCompletion()
|
||||
const redirect = sanitizeRedirectPath(
|
||||
completion.redirect || (route.query.redirect as string | undefined) || '/dashboard'
|
||||
)
|
||||
applyAdoptionSuggestionState(completion)
|
||||
redirectTo.value = redirect
|
||||
|
||||
if (completion.error === 'invitation_required') {
|
||||
needsInvitation.value = true
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
await authStore.setToken(token)
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirect)
|
||||
if (adoptionRequired.value && hasSuggestedProfile(completion)) {
|
||||
needsAdoptionConfirmation.value = true
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
await finalizeLogin(completion, redirect)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string } } }
|
||||
errorMessage.value = err.response?.data?.detail || err.message || t('auth.loginFailed')
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||
errorMessage.value =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
t('auth.loginFailed')
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
}
|
||||
@@ -209,4 +331,3 @@ onMounted(async () => {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -11,12 +11,17 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!backendModeEnabled && (linuxdoOAuthEnabled || oidcOAuthEnabled)" class="space-y-4">
|
||||
<div v-if="!backendModeEnabled && (linuxdoOAuthEnabled || wechatOAuthEnabled || oidcOAuthEnabled)" class="space-y-4">
|
||||
<LinuxDoOAuthSection
|
||||
v-if="linuxdoOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<WechatOAuthSection
|
||||
v-if="wechatOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<OidcOAuthSection
|
||||
v-if="oidcOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
@@ -200,6 +205,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
||||
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
|
||||
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
|
||||
import TotpLoginModal from '@/components/auth/TotpLoginModal.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
@@ -225,6 +231,7 @@ const showPassword = ref<boolean>(false)
|
||||
const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const linuxdoOAuthEnabled = ref<boolean>(false)
|
||||
const wechatOAuthEnabled = ref<boolean>(false)
|
||||
const backendModeEnabled = ref<boolean>(false)
|
||||
const oidcOAuthEnabled = ref<boolean>(false)
|
||||
const oidcOAuthProviderName = ref<string>('OIDC')
|
||||
@@ -267,6 +274,7 @@ onMounted(async () => {
|
||||
turnstileEnabled.value = settings.turnstile_enabled
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
|
||||
wechatOAuthEnabled.value = settings.wechat_oauth_enabled
|
||||
backendModeEnabled.value = settings.backend_mode_enabled
|
||||
oidcOAuthEnabled.value = settings.oidc_oauth_enabled
|
||||
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
|
||||
|
||||
@@ -15,36 +15,99 @@
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="needsInvitation" class="space-y-4">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('auth.oidc.invitationRequired', { providerName }) }}
|
||||
</p>
|
||||
<div>
|
||||
<input
|
||||
v-model="invitationCode"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
:placeholder="t('auth.invitationCodePlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
@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()"
|
||||
@click="handleSubmitInvitation"
|
||||
<div v-if="needsInvitation || needsAdoptionConfirmation" class="space-y-4">
|
||||
<div
|
||||
v-if="adoptionRequired && (suggestedDisplayName || suggestedAvatarUrl)"
|
||||
class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60"
|
||||
>
|
||||
{{
|
||||
isSubmitting
|
||||
? t('auth.oidc.completing')
|
||||
: t('auth.oidc.completeRegistration')
|
||||
}}
|
||||
</button>
|
||||
<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
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label
|
||||
v-if="suggestedDisplayName"
|
||||
class="flex items-start gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-dark-600 dark:bg-dark-900/50"
|
||||
>
|
||||
<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
|
||||
</span>
|
||||
<span class="block text-gray-500 dark:text-dark-400">
|
||||
{{ suggestedDisplayName }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label
|
||||
v-if="suggestedAvatarUrl"
|
||||
class="flex items-start gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-dark-600 dark:bg-dark-900/50"
|
||||
>
|
||||
<input v-model="adoptAvatar" type="checkbox" class="mt-1 h-4 w-4" />
|
||||
<img
|
||||
:src="suggestedAvatarUrl"
|
||||
:alt="`${providerName} avatar`"
|
||||
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
|
||||
</span>
|
||||
<span class="block break-all text-gray-500 dark:text-dark-400">
|
||||
{{ suggestedAvatarUrl }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="needsInvitation">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('auth.oidc.invitationRequired', { providerName }) }}
|
||||
</p>
|
||||
<div>
|
||||
<input
|
||||
v-model="invitationCode"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
:placeholder="t('auth.invitationCodePlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
@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()"
|
||||
@click="handleSubmitInvitation"
|
||||
>
|
||||
{{
|
||||
isSubmitting
|
||||
? t('auth.oidc.completing')
|
||||
: t('auth.oidc.completeRegistration')
|
||||
}}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="needsAdoptionConfirmation">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Review the {{ providerName }} profile details before continuing.
|
||||
</p>
|
||||
<button class="btn btn-primary w-full" :disabled="isSubmitting" @click="handleContinueLogin">
|
||||
{{ isSubmitting ? t('common.processing') : 'Continue' }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
@@ -81,7 +144,10 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import {
|
||||
completeOIDCOAuthRegistration,
|
||||
getPublicSettings
|
||||
exchangePendingOAuthCompletion,
|
||||
getPublicSettings,
|
||||
type OAuthAdoptionDecision,
|
||||
type PendingOAuthExchangeResponse
|
||||
} from '@/api/auth'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -95,12 +161,17 @@ const isProcessing = ref(true)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const needsInvitation = ref(false)
|
||||
const pendingOAuthToken = ref('')
|
||||
const invitationCode = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
const invitationError = ref('')
|
||||
const redirectTo = ref('/dashboard')
|
||||
const providerName = ref('OIDC')
|
||||
const adoptionRequired = ref(false)
|
||||
const suggestedDisplayName = ref('')
|
||||
const suggestedAvatarUrl = ref('')
|
||||
const adoptDisplayName = ref(true)
|
||||
const adoptAvatar = ref(true)
|
||||
const needsAdoptionConfirmation = ref(false)
|
||||
|
||||
function parseFragmentParams(): URLSearchParams {
|
||||
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
||||
@@ -129,6 +200,54 @@ async function loadProviderName() {
|
||||
}
|
||||
}
|
||||
|
||||
function currentAdoptionDecision(): OAuthAdoptionDecision {
|
||||
return {
|
||||
adoptDisplayName: adoptDisplayName.value,
|
||||
adoptAvatar: adoptAvatar.value
|
||||
}
|
||||
}
|
||||
|
||||
function applyAdoptionSuggestionState(completion: {
|
||||
adoption_required?: boolean
|
||||
suggested_display_name?: string
|
||||
suggested_avatar_url?: string
|
||||
}) {
|
||||
adoptionRequired.value = completion.adoption_required === true
|
||||
suggestedDisplayName.value = completion.suggested_display_name || ''
|
||||
suggestedAvatarUrl.value = completion.suggested_avatar_url || ''
|
||||
|
||||
if (!suggestedDisplayName.value) {
|
||||
adoptDisplayName.value = false
|
||||
}
|
||||
if (!suggestedAvatarUrl.value) {
|
||||
adoptAvatar.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function hasSuggestedProfile(completion: {
|
||||
suggested_display_name?: string
|
||||
suggested_avatar_url?: string
|
||||
}): boolean {
|
||||
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
|
||||
}
|
||||
|
||||
async function finalizeLogin(completion: PendingOAuthExchangeResponse, redirect: string) {
|
||||
if (!completion.access_token) {
|
||||
throw new Error(t('auth.oidc.callbackMissingToken'))
|
||||
}
|
||||
|
||||
if (completion.refresh_token) {
|
||||
localStorage.setItem('refresh_token', completion.refresh_token)
|
||||
}
|
||||
if (completion.expires_in) {
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + completion.expires_in * 1000))
|
||||
}
|
||||
|
||||
await authStore.setToken(completion.access_token)
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirect)
|
||||
}
|
||||
|
||||
async function handleSubmitInvitation() {
|
||||
invitationError.value = ''
|
||||
if (!invitationCode.value.trim()) return
|
||||
@@ -136,8 +255,8 @@ async function handleSubmitInvitation() {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const tokenData = await completeOIDCOAuthRegistration(
|
||||
pendingOAuthToken.value,
|
||||
invitationCode.value.trim()
|
||||
invitationCode.value.trim(),
|
||||
currentAdoptionDecision()
|
||||
)
|
||||
if (tokenData.refresh_token) {
|
||||
localStorage.setItem('refresh_token', tokenData.refresh_token)
|
||||
@@ -157,63 +276,67 @@ async function handleSubmitInvitation() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleContinueLogin() {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const completion = await exchangePendingOAuthCompletion(currentAdoptionDecision())
|
||||
await finalizeLogin(completion, redirectTo.value)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||
errorMessage.value =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
t('auth.loginFailed')
|
||||
appStore.showError(errorMessage.value)
|
||||
needsAdoptionConfirmation.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
void loadProviderName()
|
||||
|
||||
const params = parseFragmentParams()
|
||||
const token = params.get('access_token') || ''
|
||||
const refreshToken = params.get('refresh_token') || ''
|
||||
const expiresInStr = params.get('expires_in') || ''
|
||||
const redirect = sanitizeRedirectPath(
|
||||
params.get('redirect') || (route.query.redirect as string | undefined) || '/dashboard'
|
||||
)
|
||||
const error = params.get('error')
|
||||
const errorDesc = params.get('error_description') || params.get('error_message') || ''
|
||||
|
||||
if (error) {
|
||||
if (error === 'invitation_required') {
|
||||
pendingOAuthToken.value = params.get('pending_oauth_token') || ''
|
||||
redirectTo.value = sanitizeRedirectPath(params.get('redirect'))
|
||||
if (!pendingOAuthToken.value) {
|
||||
errorMessage.value = t('auth.oidc.invalidPendingToken')
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
needsInvitation.value = true
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
errorMessage.value = errorDesc || error
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
errorMessage.value = t('auth.oidc.callbackMissingToken')
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('refresh_token', refreshToken)
|
||||
}
|
||||
if (expiresInStr) {
|
||||
const expiresIn = parseInt(expiresInStr, 10)
|
||||
if (!isNaN(expiresIn)) {
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + expiresIn * 1000))
|
||||
}
|
||||
const completion = await exchangePendingOAuthCompletion()
|
||||
const redirect = sanitizeRedirectPath(
|
||||
completion.redirect || (route.query.redirect as string | undefined) || '/dashboard'
|
||||
)
|
||||
applyAdoptionSuggestionState(completion)
|
||||
redirectTo.value = redirect
|
||||
|
||||
if (completion.error === 'invitation_required') {
|
||||
needsInvitation.value = true
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
await authStore.setToken(token)
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirect)
|
||||
if (adoptionRequired.value && hasSuggestedProfile(completion)) {
|
||||
needsAdoptionConfirmation.value = true
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
await finalizeLogin(completion, redirect)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string } } }
|
||||
errorMessage.value = err.response?.data?.detail || err.message || t('auth.loginFailed')
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||
errorMessage.value =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
t('auth.loginFailed')
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
}
|
||||
|
||||
@@ -11,12 +11,17 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="linuxdoOAuthEnabled || oidcOAuthEnabled" class="space-y-4">
|
||||
<div v-if="linuxdoOAuthEnabled || wechatOAuthEnabled || oidcOAuthEnabled" class="space-y-4">
|
||||
<LinuxDoOAuthSection
|
||||
v-if="linuxdoOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<WechatOAuthSection
|
||||
v-if="wechatOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<OidcOAuthSection
|
||||
v-if="oidcOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
@@ -308,6 +313,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
||||
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
|
||||
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
@@ -343,6 +349,7 @@ const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const siteName = ref<string>('Sub2API')
|
||||
const linuxdoOAuthEnabled = ref<boolean>(false)
|
||||
const wechatOAuthEnabled = ref<boolean>(false)
|
||||
const oidcOAuthEnabled = ref<boolean>(false)
|
||||
const oidcOAuthProviderName = ref<string>('OIDC')
|
||||
const registrationEmailSuffixWhitelist = ref<string[]>([])
|
||||
@@ -397,6 +404,7 @@ onMounted(async () => {
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
siteName.value = settings.site_name || 'Sub2API'
|
||||
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
|
||||
wechatOAuthEnabled.value = settings.wechat_oauth_enabled
|
||||
oidcOAuthEnabled.value = settings.oidc_oauth_enabled
|
||||
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
|
||||
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
|
||||
|
||||
361
frontend/src/views/auth/WechatCallbackView.vue
Normal file
361
frontend/src/views/auth/WechatCallbackView.vue
Normal file
@@ -0,0 +1,361 @@
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<div class="space-y-6">
|
||||
<div class="text-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ t('auth.oidc.callbackTitle', { providerName }) }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{
|
||||
isProcessing
|
||||
? t('auth.oidc.callbackProcessing', { providerName })
|
||||
: t('auth.oidc.callbackHint')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="needsInvitation || needsAdoptionConfirmation" class="space-y-4">
|
||||
<div
|
||||
v-if="adoptionRequired && (suggestedDisplayName || suggestedAvatarUrl)"
|
||||
class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60"
|
||||
>
|
||||
<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
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label
|
||||
v-if="suggestedDisplayName"
|
||||
class="flex items-start gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-dark-600 dark:bg-dark-900/50"
|
||||
>
|
||||
<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
|
||||
</span>
|
||||
<span class="block text-gray-500 dark:text-dark-400">
|
||||
{{ suggestedDisplayName }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label
|
||||
v-if="suggestedAvatarUrl"
|
||||
class="flex items-start gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-dark-600 dark:bg-dark-900/50"
|
||||
>
|
||||
<input v-model="adoptAvatar" type="checkbox" class="mt-1 h-4 w-4" />
|
||||
<img
|
||||
:src="suggestedAvatarUrl"
|
||||
:alt="`${providerName} avatar`"
|
||||
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
|
||||
</span>
|
||||
<span class="block break-all text-gray-500 dark:text-dark-400">
|
||||
{{ suggestedAvatarUrl }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="needsInvitation">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('auth.oidc.invitationRequired', { providerName }) }}
|
||||
</p>
|
||||
<div>
|
||||
<input
|
||||
v-model="invitationCode"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
:placeholder="t('auth.invitationCodePlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
@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()"
|
||||
@click="handleSubmitInvitation"
|
||||
>
|
||||
{{
|
||||
isSubmitting
|
||||
? t('auth.oidc.completing')
|
||||
: t('auth.oidc.completeRegistration')
|
||||
}}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="needsAdoptionConfirmation">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Review the {{ providerName }} profile details before continuing.
|
||||
</p>
|
||||
<button class="btn btn-primary w-full" :disabled="isSubmitting" @click="handleContinueLogin">
|
||||
{{ isSubmitting ? t('common.processing') : 'Continue' }}
|
||||
</button>
|
||||
</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 { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { apiClient } from '@/api/client'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
|
||||
interface OAuthTokenResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}
|
||||
|
||||
interface PendingOAuthExchangeResponse {
|
||||
access_token?: string
|
||||
refresh_token?: string
|
||||
expires_in?: number
|
||||
token_type?: string
|
||||
redirect?: string
|
||||
error?: string
|
||||
adoption_required?: boolean
|
||||
suggested_display_name?: string
|
||||
suggested_avatar_url?: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const isProcessing = ref(true)
|
||||
const errorMessage = ref('')
|
||||
const needsInvitation = ref(false)
|
||||
const invitationCode = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
const invitationError = ref('')
|
||||
const redirectTo = ref('/dashboard')
|
||||
const adoptionRequired = ref(false)
|
||||
const suggestedDisplayName = ref('')
|
||||
const suggestedAvatarUrl = ref('')
|
||||
const adoptDisplayName = ref(true)
|
||||
const adoptAvatar = ref(true)
|
||||
const needsAdoptionConfirmation = ref(false)
|
||||
|
||||
const providerName = 'WeChat'
|
||||
|
||||
function parseFragmentParams(): URLSearchParams {
|
||||
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
||||
const hash = raw.startsWith('#') ? raw.slice(1) : raw
|
||||
return new URLSearchParams(hash)
|
||||
}
|
||||
|
||||
function sanitizeRedirectPath(path: string | null | undefined): string {
|
||||
if (!path) return '/dashboard'
|
||||
if (!path.startsWith('/')) return '/dashboard'
|
||||
if (path.startsWith('//')) return '/dashboard'
|
||||
if (path.includes('://')) return '/dashboard'
|
||||
if (path.includes('\n') || path.includes('\r')) return '/dashboard'
|
||||
return path
|
||||
}
|
||||
|
||||
function currentAdoptionDecision(): Record<string, boolean> {
|
||||
return {
|
||||
adopt_display_name: adoptDisplayName.value,
|
||||
adopt_avatar: adoptAvatar.value,
|
||||
}
|
||||
}
|
||||
|
||||
function applyAdoptionSuggestionState(completion: PendingOAuthExchangeResponse) {
|
||||
adoptionRequired.value = completion.adoption_required === true
|
||||
suggestedDisplayName.value = completion.suggested_display_name || ''
|
||||
suggestedAvatarUrl.value = completion.suggested_avatar_url || ''
|
||||
|
||||
if (!suggestedDisplayName.value) {
|
||||
adoptDisplayName.value = false
|
||||
}
|
||||
if (!suggestedAvatarUrl.value) {
|
||||
adoptAvatar.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function hasSuggestedProfile(completion: PendingOAuthExchangeResponse): boolean {
|
||||
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
|
||||
}
|
||||
|
||||
async function exchangePendingOAuthCompletion(): Promise<PendingOAuthExchangeResponse> {
|
||||
const { data } = await apiClient.post<PendingOAuthExchangeResponse>('/auth/oauth/pending/exchange', {})
|
||||
return data
|
||||
}
|
||||
|
||||
async function finalizeLogin(completion: PendingOAuthExchangeResponse, redirect: string) {
|
||||
if (!completion.access_token) {
|
||||
throw new Error(t('auth.oidc.callbackMissingToken'))
|
||||
}
|
||||
|
||||
if (completion.refresh_token) {
|
||||
localStorage.setItem('refresh_token', completion.refresh_token)
|
||||
}
|
||||
if (completion.expires_in) {
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + completion.expires_in * 1000))
|
||||
}
|
||||
|
||||
await authStore.setToken(completion.access_token)
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirect)
|
||||
}
|
||||
|
||||
async function completeWeChatOAuthRegistration(invitation: string): Promise<OAuthTokenResponse> {
|
||||
const { data } = await apiClient.post<OAuthTokenResponse>('/auth/oauth/wechat/complete-registration', {
|
||||
invitation_code: invitation,
|
||||
...currentAdoptionDecision(),
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
async function handleSubmitInvitation() {
|
||||
invitationError.value = ''
|
||||
if (!invitationCode.value.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const tokenData = await completeWeChatOAuthRegistration(invitationCode.value.trim())
|
||||
if (tokenData.refresh_token) {
|
||||
localStorage.setItem('refresh_token', tokenData.refresh_token)
|
||||
}
|
||||
if (tokenData.expires_in) {
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + tokenData.expires_in * 1000))
|
||||
}
|
||||
await authStore.setToken(tokenData.access_token)
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirectTo.value)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { message?: string } } }
|
||||
invitationError.value =
|
||||
err.response?.data?.message || err.message || t('auth.oidc.completeRegistrationFailed')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleContinueLogin() {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const { data } = await apiClient.post<PendingOAuthExchangeResponse>(
|
||||
'/auth/oauth/pending/exchange',
|
||||
currentAdoptionDecision()
|
||||
)
|
||||
await finalizeLogin(data, redirectTo.value)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||
errorMessage.value =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
t('auth.loginFailed')
|
||||
appStore.showError(errorMessage.value)
|
||||
needsAdoptionConfirmation.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const params = parseFragmentParams()
|
||||
const error = params.get('error')
|
||||
const errorDesc = params.get('error_description') || params.get('error_message') || ''
|
||||
|
||||
if (error) {
|
||||
errorMessage.value = errorDesc || error
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const completion = await exchangePendingOAuthCompletion()
|
||||
const redirect = sanitizeRedirectPath(
|
||||
completion.redirect || (route.query.redirect as string | undefined) || '/dashboard'
|
||||
)
|
||||
applyAdoptionSuggestionState(completion)
|
||||
redirectTo.value = redirect
|
||||
|
||||
if (completion.error === 'invitation_required') {
|
||||
needsInvitation.value = true
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (adoptionRequired.value && hasSuggestedProfile(completion)) {
|
||||
needsAdoptionConfirmation.value = true
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
await finalizeLogin(completion, redirect)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||
errorMessage.value =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
t('auth.loginFailed')
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
180
frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts
Normal file
180
frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
import LinuxDoCallbackView from '../LinuxDoCallbackView.vue'
|
||||
|
||||
const replace = vi.fn()
|
||||
const showSuccess = vi.fn()
|
||||
const showError = vi.fn()
|
||||
const setToken = vi.fn()
|
||||
const exchangePendingOAuthCompletion = vi.fn()
|
||||
const completeLinuxDoOAuthRegistration = vi.fn()
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({
|
||||
query: {}
|
||||
}),
|
||||
useRouter: () => ({
|
||||
replace
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useAuthStore: () => ({
|
||||
setToken
|
||||
}),
|
||||
useAppStore: () => ({
|
||||
showSuccess,
|
||||
showError
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args),
|
||||
completeLinuxDoOAuthRegistration: (...args: any[]) => completeLinuxDoOAuthRegistration(...args)
|
||||
}))
|
||||
|
||||
describe('LinuxDoCallbackView', () => {
|
||||
beforeEach(() => {
|
||||
replace.mockReset()
|
||||
showSuccess.mockReset()
|
||||
showError.mockReset()
|
||||
setToken.mockReset()
|
||||
exchangePendingOAuthCompletion.mockReset()
|
||||
completeLinuxDoOAuthRegistration.mockReset()
|
||||
})
|
||||
|
||||
it('does not send adoption decisions during the initial exchange', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
redirect: '/dashboard',
|
||||
adoption_required: true
|
||||
})
|
||||
setToken.mockResolvedValue({})
|
||||
|
||||
mount(LinuxDoCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenCalledTimes(1)
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('waits for explicit adoption confirmation before finishing a non-invitation login', async () => {
|
||||
exchangePendingOAuthCompletion
|
||||
.mockResolvedValueOnce({
|
||||
redirect: '/dashboard',
|
||||
adoption_required: true,
|
||||
suggested_display_name: 'LinuxDo Nick',
|
||||
suggested_avatar_url: 'https://cdn.example/linuxdo.png'
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
redirect: '/dashboard'
|
||||
})
|
||||
setToken.mockResolvedValue({})
|
||||
|
||||
const wrapper = mount(LinuxDoCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('LinuxDo Nick')
|
||||
expect(setToken).not.toHaveBeenCalled()
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
await checkboxes[1].setValue(false)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(1)
|
||||
await buttons[0].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenCalledTimes(2)
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenNthCalledWith(1)
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenNthCalledWith(2, {
|
||||
adoptDisplayName: true,
|
||||
adoptAvatar: false
|
||||
})
|
||||
expect(setToken).toHaveBeenCalledWith('access-token')
|
||||
expect(replace).toHaveBeenCalledWith('/dashboard')
|
||||
})
|
||||
|
||||
it('renders adoption choices for invitation flow and submits the selected values', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||
error: 'invitation_required',
|
||||
redirect: '/dashboard',
|
||||
adoption_required: true,
|
||||
suggested_display_name: 'LinuxDo Nick',
|
||||
suggested_avatar_url: 'https://cdn.example/linuxdo.png'
|
||||
})
|
||||
completeLinuxDoOAuthRegistration.mockResolvedValue({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer'
|
||||
})
|
||||
setToken.mockResolvedValue({})
|
||||
|
||||
const wrapper = mount(LinuxDoCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('LinuxDo Nick')
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenCalledTimes(1)
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenCalledWith()
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
expect(checkboxes).toHaveLength(2)
|
||||
|
||||
await checkboxes[0].setValue(false)
|
||||
await wrapper.find('input[type="text"]').setValue('invite-code')
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
expect(completeLinuxDoOAuthRegistration).toHaveBeenCalledWith('invite-code', {
|
||||
adoptDisplayName: false,
|
||||
adoptAvatar: true
|
||||
})
|
||||
})
|
||||
})
|
||||
191
frontend/src/views/auth/__tests__/OidcCallbackView.spec.ts
Normal file
191
frontend/src/views/auth/__tests__/OidcCallbackView.spec.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
import OidcCallbackView from '../OidcCallbackView.vue'
|
||||
|
||||
const replace = vi.fn()
|
||||
const showSuccess = vi.fn()
|
||||
const showError = vi.fn()
|
||||
const setToken = vi.fn()
|
||||
const exchangePendingOAuthCompletion = vi.fn()
|
||||
const completeOIDCOAuthRegistration = vi.fn()
|
||||
const getPublicSettings = vi.fn()
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({
|
||||
query: {}
|
||||
}),
|
||||
useRouter: () => ({
|
||||
replace
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
if (!params?.providerName) {
|
||||
return key
|
||||
}
|
||||
return `${key}:${params.providerName}`
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useAuthStore: () => ({
|
||||
setToken
|
||||
}),
|
||||
useAppStore: () => ({
|
||||
showSuccess,
|
||||
showError
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args),
|
||||
completeOIDCOAuthRegistration: (...args: any[]) => completeOIDCOAuthRegistration(...args),
|
||||
getPublicSettings: (...args: any[]) => getPublicSettings(...args)
|
||||
}))
|
||||
|
||||
describe('OidcCallbackView', () => {
|
||||
beforeEach(() => {
|
||||
replace.mockReset()
|
||||
showSuccess.mockReset()
|
||||
showError.mockReset()
|
||||
setToken.mockReset()
|
||||
exchangePendingOAuthCompletion.mockReset()
|
||||
completeOIDCOAuthRegistration.mockReset()
|
||||
getPublicSettings.mockReset()
|
||||
getPublicSettings.mockResolvedValue({
|
||||
oidc_oauth_provider_name: 'ExampleID'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not send adoption decisions during the initial exchange', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
redirect: '/dashboard',
|
||||
adoption_required: true
|
||||
})
|
||||
setToken.mockResolvedValue({})
|
||||
|
||||
mount(OidcCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenCalledTimes(1)
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('waits for explicit adoption confirmation before finishing a non-invitation login', async () => {
|
||||
exchangePendingOAuthCompletion
|
||||
.mockResolvedValueOnce({
|
||||
redirect: '/dashboard',
|
||||
adoption_required: true,
|
||||
suggested_display_name: 'OIDC Nick',
|
||||
suggested_avatar_url: 'https://cdn.example/oidc.png'
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
redirect: '/dashboard'
|
||||
})
|
||||
setToken.mockResolvedValue({})
|
||||
|
||||
const wrapper = mount(OidcCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('OIDC Nick')
|
||||
expect(setToken).not.toHaveBeenCalled()
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
await checkboxes[0].setValue(false)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(1)
|
||||
await buttons[0].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenCalledTimes(2)
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenNthCalledWith(1)
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenNthCalledWith(2, {
|
||||
adoptDisplayName: false,
|
||||
adoptAvatar: true
|
||||
})
|
||||
expect(setToken).toHaveBeenCalledWith('access-token')
|
||||
expect(replace).toHaveBeenCalledWith('/dashboard')
|
||||
})
|
||||
|
||||
it('renders adoption choices for invitation flow and submits the selected values', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||
error: 'invitation_required',
|
||||
redirect: '/dashboard',
|
||||
adoption_required: true,
|
||||
suggested_display_name: 'OIDC Nick',
|
||||
suggested_avatar_url: 'https://cdn.example/oidc.png'
|
||||
})
|
||||
completeOIDCOAuthRegistration.mockResolvedValue({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer'
|
||||
})
|
||||
setToken.mockResolvedValue({})
|
||||
|
||||
const wrapper = mount(OidcCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('OIDC Nick')
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenCalledTimes(1)
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenCalledWith()
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
expect(checkboxes).toHaveLength(2)
|
||||
|
||||
await checkboxes[1].setValue(false)
|
||||
await wrapper.find('input[type="text"]').setValue('invite-code')
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
expect(completeOIDCOAuthRegistration).toHaveBeenCalledWith('invite-code', {
|
||||
adoptDisplayName: true,
|
||||
adoptAvatar: false
|
||||
})
|
||||
})
|
||||
})
|
||||
241
frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts
Normal file
241
frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import WechatCallbackView from '@/views/auth/WechatCallbackView.vue'
|
||||
|
||||
const {
|
||||
postMock,
|
||||
replaceMock,
|
||||
setTokenMock,
|
||||
showSuccessMock,
|
||||
showErrorMock,
|
||||
routeState,
|
||||
} = vi.hoisted(() => ({
|
||||
postMock: vi.fn(),
|
||||
replaceMock: vi.fn(),
|
||||
setTokenMock: vi.fn(),
|
||||
showSuccessMock: vi.fn(),
|
||||
showErrorMock: vi.fn(),
|
||||
routeState: {
|
||||
query: {} as Record<string, unknown>,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => routeState,
|
||||
useRouter: () => ({
|
||||
replace: replaceMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
createI18n: () => ({
|
||||
global: {
|
||||
t: (key: string) => key,
|
||||
},
|
||||
}),
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
if (key === 'auth.oidc.callbackTitle') {
|
||||
return `Signing you in with ${params?.providerName ?? ''}`.trim()
|
||||
}
|
||||
if (key === 'auth.oidc.callbackProcessing') {
|
||||
return `Completing login with ${params?.providerName ?? ''}`.trim()
|
||||
}
|
||||
if (key === 'auth.oidc.invitationRequired') {
|
||||
return `${params?.providerName ?? ''} invitation required`.trim()
|
||||
}
|
||||
if (key === 'auth.oidc.completeRegistration') {
|
||||
return 'Complete registration'
|
||||
}
|
||||
if (key === 'auth.oidc.completing') {
|
||||
return 'Completing'
|
||||
}
|
||||
if (key === 'auth.oidc.backToLogin') {
|
||||
return 'Back to login'
|
||||
}
|
||||
if (key === 'auth.invitationCodePlaceholder') {
|
||||
return 'Invitation code'
|
||||
}
|
||||
if (key === 'auth.loginSuccess') {
|
||||
return 'Login success'
|
||||
}
|
||||
if (key === 'auth.loginFailed') {
|
||||
return 'Login failed'
|
||||
}
|
||||
if (key === 'auth.oidc.callbackHint') {
|
||||
return 'Callback hint'
|
||||
}
|
||||
if (key === 'auth.oidc.callbackMissingToken') {
|
||||
return 'Missing login token'
|
||||
}
|
||||
if (key === 'auth.oidc.completeRegistrationFailed') {
|
||||
return 'Complete registration failed'
|
||||
}
|
||||
return key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useAuthStore: () => ({
|
||||
setToken: setTokenMock,
|
||||
}),
|
||||
useAppStore: () => ({
|
||||
showSuccess: showSuccessMock,
|
||||
showError: showErrorMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: {
|
||||
post: postMock,
|
||||
},
|
||||
}))
|
||||
|
||||
describe('WechatCallbackView', () => {
|
||||
beforeEach(() => {
|
||||
postMock.mockReset()
|
||||
replaceMock.mockReset()
|
||||
setTokenMock.mockReset()
|
||||
showSuccessMock.mockReset()
|
||||
showErrorMock.mockReset()
|
||||
routeState.query = {}
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('does not send adoption decisions during the initial exchange', async () => {
|
||||
postMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
redirect: '/dashboard',
|
||||
adoption_required: true,
|
||||
},
|
||||
})
|
||||
setTokenMock.mockResolvedValue({})
|
||||
|
||||
mount(WechatCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('/auth/oauth/pending/exchange', {})
|
||||
expect(postMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('waits for explicit adoption confirmation before finishing a non-invitation login', async () => {
|
||||
postMock
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
redirect: '/dashboard',
|
||||
adoption_required: true,
|
||||
suggested_display_name: 'WeChat Nick',
|
||||
suggested_avatar_url: 'https://cdn.example/wechat.png',
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
access_token: 'wechat-access-token',
|
||||
refresh_token: 'wechat-refresh-token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
redirect: '/dashboard',
|
||||
},
|
||||
})
|
||||
setTokenMock.mockResolvedValue({})
|
||||
|
||||
const wrapper = mount(WechatCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('WeChat Nick')
|
||||
expect(setTokenMock).not.toHaveBeenCalled()
|
||||
expect(replaceMock).not.toHaveBeenCalled()
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
expect(checkboxes).toHaveLength(2)
|
||||
await checkboxes[1].setValue(false)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(1)
|
||||
await buttons[0].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(postMock).toHaveBeenNthCalledWith(1, '/auth/oauth/pending/exchange', {})
|
||||
expect(postMock).toHaveBeenNthCalledWith(2, '/auth/oauth/pending/exchange', {
|
||||
adopt_display_name: true,
|
||||
adopt_avatar: false,
|
||||
})
|
||||
expect(setTokenMock).toHaveBeenCalledWith('wechat-access-token')
|
||||
expect(replaceMock).toHaveBeenCalledWith('/dashboard')
|
||||
expect(localStorage.getItem('refresh_token')).toBe('wechat-refresh-token')
|
||||
})
|
||||
|
||||
it('renders adoption choices for invitation flow and submits the selected values', async () => {
|
||||
postMock
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
error: 'invitation_required',
|
||||
redirect: '/subscriptions',
|
||||
adoption_required: true,
|
||||
suggested_display_name: 'WeChat Nick',
|
||||
suggested_avatar_url: 'https://cdn.example/wechat.png',
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
access_token: 'wechat-invite-token',
|
||||
refresh_token: 'wechat-invite-refresh',
|
||||
expires_in: 600,
|
||||
token_type: 'Bearer',
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = mount(WechatCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('WeChat Nick')
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
expect(checkboxes).toHaveLength(2)
|
||||
await checkboxes[0].setValue(false)
|
||||
await wrapper.get('input[type="text"]').setValue(' INVITE-CODE ')
|
||||
await wrapper.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(postMock).toHaveBeenNthCalledWith(2, '/auth/oauth/wechat/complete-registration', {
|
||||
invitation_code: 'INVITE-CODE',
|
||||
adopt_display_name: false,
|
||||
adopt_avatar: true,
|
||||
})
|
||||
expect(setTokenMock).toHaveBeenCalledWith('wechat-invite-token')
|
||||
expect(replaceMock).toHaveBeenCalledWith('/subscriptions')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user