feat: add oauth callback email binding ui
This commit is contained in:
@@ -30,6 +30,20 @@ describe('oauth adoption auth api', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('posts bind-login decisions when finalizing pending oauth bind flow', async () => {
|
||||||
|
const { completePendingOAuthBindLogin } = await import('@/api/auth')
|
||||||
|
|
||||||
|
await completePendingOAuthBindLogin({
|
||||||
|
adoptDisplayName: true,
|
||||||
|
adoptAvatar: false
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(post).toHaveBeenCalledWith('/auth/oauth/pending/exchange', {
|
||||||
|
adopt_display_name: true,
|
||||||
|
adopt_avatar: false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('posts linuxdo invitation completion with adoption decisions', async () => {
|
it('posts linuxdo invitation completion with adoption decisions', async () => {
|
||||||
const { completeLinuxDoOAuthRegistration } = await import('@/api/auth')
|
const { completeLinuxDoOAuthRegistration } = await import('@/api/auth')
|
||||||
|
|
||||||
@@ -45,6 +59,21 @@ describe('oauth adoption auth api', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('posts linuxdo create-account completion with adoption decisions', async () => {
|
||||||
|
const { createPendingLinuxDoOAuthAccount } = await import('@/api/auth')
|
||||||
|
|
||||||
|
await createPendingLinuxDoOAuthAccount('invite-code', {
|
||||||
|
adoptDisplayName: false,
|
||||||
|
adoptAvatar: true
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(post).toHaveBeenCalledWith('/auth/oauth/linuxdo/complete-registration', {
|
||||||
|
invitation_code: 'invite-code',
|
||||||
|
adopt_display_name: false,
|
||||||
|
adopt_avatar: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('posts oidc invitation completion with adoption decisions', async () => {
|
it('posts oidc invitation completion with adoption decisions', async () => {
|
||||||
const { completeOIDCOAuthRegistration } = await import('@/api/auth')
|
const { completeOIDCOAuthRegistration } = await import('@/api/auth')
|
||||||
|
|
||||||
@@ -60,6 +89,21 @@ describe('oauth adoption auth api', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('posts oidc create-account completion with adoption decisions', async () => {
|
||||||
|
const { createPendingOIDCOAuthAccount } = await import('@/api/auth')
|
||||||
|
|
||||||
|
await createPendingOIDCOAuthAccount('invite-code', {
|
||||||
|
adoptDisplayName: true,
|
||||||
|
adoptAvatar: false
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(post).toHaveBeenCalledWith('/auth/oauth/oidc/complete-registration', {
|
||||||
|
invitation_code: 'invite-code',
|
||||||
|
adopt_display_name: true,
|
||||||
|
adopt_avatar: false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('posts wechat invitation completion with adoption decisions', async () => {
|
it('posts wechat invitation completion with adoption decisions', async () => {
|
||||||
const { completeWeChatOAuthRegistration } = await import('@/api/auth')
|
const { completeWeChatOAuthRegistration } = await import('@/api/auth')
|
||||||
|
|
||||||
@@ -75,6 +119,21 @@ describe('oauth adoption auth api', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('posts wechat create-account completion with adoption decisions', async () => {
|
||||||
|
const { createPendingWeChatOAuthAccount } = await import('@/api/auth')
|
||||||
|
|
||||||
|
await createPendingWeChatOAuthAccount('invite-code', {
|
||||||
|
adoptDisplayName: false,
|
||||||
|
adoptAvatar: false
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(post).toHaveBeenCalledWith('/auth/oauth/wechat/complete-registration', {
|
||||||
|
invitation_code: 'invite-code',
|
||||||
|
adopt_display_name: false,
|
||||||
|
adopt_avatar: false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('classifies oauth completion results as login or bind', async () => {
|
it('classifies oauth completion results as login or bind', async () => {
|
||||||
const { getOAuthCompletionKind } = await import('@/api/auth')
|
const { getOAuthCompletionKind } = await import('@/api/auth')
|
||||||
|
|
||||||
@@ -82,6 +141,38 @@ describe('oauth adoption auth api', () => {
|
|||||||
expect(getOAuthCompletionKind({ redirect: '/profile' })).toBe('bind')
|
expect(getOAuthCompletionKind({ redirect: '/profile' })).toBe('bind')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('provides bind-login utility helpers for invitation and suggested profile states', async () => {
|
||||||
|
const {
|
||||||
|
getPendingOAuthBindLoginKind,
|
||||||
|
hasPendingOAuthSuggestedProfile,
|
||||||
|
isPendingOAuthCreateAccountRequired
|
||||||
|
} = await import('@/api/auth')
|
||||||
|
|
||||||
|
expect(getPendingOAuthBindLoginKind({ access_token: 'access-token' })).toBe('login')
|
||||||
|
expect(getPendingOAuthBindLoginKind({ redirect: '/profile' })).toBe('bind')
|
||||||
|
expect(
|
||||||
|
isPendingOAuthCreateAccountRequired({
|
||||||
|
error: 'invitation_required'
|
||||||
|
})
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
isPendingOAuthCreateAccountRequired({
|
||||||
|
error: 'other'
|
||||||
|
})
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
hasPendingOAuthSuggestedProfile({
|
||||||
|
suggested_display_name: 'OAuth Nick'
|
||||||
|
})
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
hasPendingOAuthSuggestedProfile({
|
||||||
|
suggested_avatar_url: 'https://cdn.example/avatar.png'
|
||||||
|
})
|
||||||
|
).toBe(true)
|
||||||
|
expect(hasPendingOAuthSuggestedProfile({})).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('prepares an oauth bind access token cookie before redirect binding', async () => {
|
it('prepares an oauth bind access token cookie before redirect binding', async () => {
|
||||||
localStorage.setItem('auth_token', 'access-token-value')
|
localStorage.setItem('auth_token', 'access-token-value')
|
||||||
const setCookie = vi.fn()
|
const setCookie = vi.fn()
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ export interface OAuthTokenResponse {
|
|||||||
token_type?: string
|
token_type?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PendingOAuthExchangeResponse extends Partial<OAuthTokenResponse> {
|
export interface PendingOAuthBindLoginResponse extends Partial<OAuthTokenResponse> {
|
||||||
redirect?: string
|
redirect?: string
|
||||||
error?: string
|
error?: string
|
||||||
adoption_required?: boolean
|
adoption_required?: boolean
|
||||||
@@ -201,6 +201,10 @@ export interface PendingOAuthExchangeResponse extends Partial<OAuthTokenResponse
|
|||||||
suggested_avatar_url?: string
|
suggested_avatar_url?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PendingOAuthExchangeResponse = PendingOAuthBindLoginResponse
|
||||||
|
|
||||||
|
export interface PendingOAuthCreateAccountResponse extends OAuthTokenResponse {}
|
||||||
|
|
||||||
export type OAuthCompletionKind = 'login' | 'bind'
|
export type OAuthCompletionKind = 'login' | 'bind'
|
||||||
|
|
||||||
export interface OAuthAdoptionDecision {
|
export interface OAuthAdoptionDecision {
|
||||||
@@ -235,6 +239,27 @@ export function getOAuthCompletionKind(
|
|||||||
return isOAuthLoginCompletion(completion) ? 'login' : 'bind'
|
return isOAuthLoginCompletion(completion) ? 'login' : 'bind'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPendingOAuthBindLoginKind(
|
||||||
|
completion: PendingOAuthBindLoginResponse
|
||||||
|
): OAuthCompletionKind {
|
||||||
|
return getOAuthCompletionKind(completion)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPendingOAuthCreateAccountRequired(
|
||||||
|
completion: Pick<PendingOAuthBindLoginResponse, 'error'>
|
||||||
|
): boolean {
|
||||||
|
return completion.error === 'invitation_required'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPendingOAuthSuggestedProfile(
|
||||||
|
completion: Pick<
|
||||||
|
PendingOAuthBindLoginResponse,
|
||||||
|
'suggested_display_name' | 'suggested_avatar_url'
|
||||||
|
>
|
||||||
|
): boolean {
|
||||||
|
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
|
||||||
|
}
|
||||||
|
|
||||||
export function persistOAuthTokenContext(tokens: Partial<OAuthTokenResponse>): void {
|
export function persistOAuthTokenContext(tokens: Partial<OAuthTokenResponse>): void {
|
||||||
if (tokens.refresh_token) {
|
if (tokens.refresh_token) {
|
||||||
setRefreshToken(tokens.refresh_token)
|
setRefreshToken(tokens.refresh_token)
|
||||||
@@ -431,11 +456,7 @@ export async function completeLinuxDoOAuthRegistration(
|
|||||||
invitationCode: string,
|
invitationCode: string,
|
||||||
decision?: OAuthAdoptionDecision
|
decision?: OAuthAdoptionDecision
|
||||||
): Promise<OAuthTokenResponse> {
|
): Promise<OAuthTokenResponse> {
|
||||||
const { data } = await apiClient.post<OAuthTokenResponse>('/auth/oauth/linuxdo/complete-registration', {
|
return createPendingLinuxDoOAuthAccount(invitationCode, decision)
|
||||||
invitation_code: invitationCode,
|
|
||||||
...serializeOAuthAdoptionDecision(decision)
|
|
||||||
})
|
|
||||||
return data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -447,32 +468,66 @@ export async function completeOIDCOAuthRegistration(
|
|||||||
invitationCode: string,
|
invitationCode: string,
|
||||||
decision?: OAuthAdoptionDecision
|
decision?: OAuthAdoptionDecision
|
||||||
): Promise<OAuthTokenResponse> {
|
): Promise<OAuthTokenResponse> {
|
||||||
const { data } = await apiClient.post<OAuthTokenResponse>('/auth/oauth/oidc/complete-registration', {
|
return createPendingOIDCOAuthAccount(invitationCode, decision)
|
||||||
invitation_code: invitationCode,
|
|
||||||
...serializeOAuthAdoptionDecision(decision)
|
|
||||||
})
|
|
||||||
return data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function completeWeChatOAuthRegistration(
|
export async function completeWeChatOAuthRegistration(
|
||||||
invitationCode: string,
|
invitationCode: string,
|
||||||
decision?: OAuthAdoptionDecision
|
decision?: OAuthAdoptionDecision
|
||||||
): Promise<OAuthTokenResponse> {
|
): Promise<OAuthTokenResponse> {
|
||||||
const { data } = await apiClient.post<OAuthTokenResponse>('/auth/oauth/wechat/complete-registration', {
|
return createPendingWeChatOAuthAccount(invitationCode, decision)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPendingOAuthAccount(
|
||||||
|
provider: 'linuxdo' | 'oidc' | 'wechat',
|
||||||
|
invitationCode: string,
|
||||||
|
decision?: OAuthAdoptionDecision
|
||||||
|
): Promise<PendingOAuthCreateAccountResponse> {
|
||||||
|
const { data } = await apiClient.post<PendingOAuthCreateAccountResponse>(
|
||||||
|
`/auth/oauth/${provider}/complete-registration`,
|
||||||
|
{
|
||||||
invitation_code: invitationCode,
|
invitation_code: invitationCode,
|
||||||
...serializeOAuthAdoptionDecision(decision)
|
...serializeOAuthAdoptionDecision(decision)
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPendingLinuxDoOAuthAccount(
|
||||||
|
invitationCode: string,
|
||||||
|
decision?: OAuthAdoptionDecision
|
||||||
|
): Promise<PendingOAuthCreateAccountResponse> {
|
||||||
|
return createPendingOAuthAccount('linuxdo', invitationCode, decision)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPendingOIDCOAuthAccount(
|
||||||
|
invitationCode: string,
|
||||||
|
decision?: OAuthAdoptionDecision
|
||||||
|
): Promise<PendingOAuthCreateAccountResponse> {
|
||||||
|
return createPendingOAuthAccount('oidc', invitationCode, decision)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPendingWeChatOAuthAccount(
|
||||||
|
invitationCode: string,
|
||||||
|
decision?: OAuthAdoptionDecision
|
||||||
|
): Promise<PendingOAuthCreateAccountResponse> {
|
||||||
|
return createPendingOAuthAccount('wechat', invitationCode, decision)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completePendingOAuthBindLogin(
|
||||||
|
decision?: OAuthAdoptionDecision
|
||||||
|
): Promise<PendingOAuthBindLoginResponse> {
|
||||||
|
const { data } = await apiClient.post<PendingOAuthBindLoginResponse>(
|
||||||
|
'/auth/oauth/pending/exchange',
|
||||||
|
serializeOAuthAdoptionDecision(decision)
|
||||||
|
)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exchangePendingOAuthCompletion(
|
export async function exchangePendingOAuthCompletion(
|
||||||
decision?: OAuthAdoptionDecision
|
decision?: OAuthAdoptionDecision
|
||||||
): Promise<PendingOAuthExchangeResponse> {
|
): Promise<PendingOAuthExchangeResponse> {
|
||||||
const { data } = await apiClient.post<PendingOAuthExchangeResponse>(
|
return completePendingOAuthBindLogin(decision)
|
||||||
'/auth/oauth/pending/exchange',
|
|
||||||
serializeOAuthAdoptionDecision(decision)
|
|
||||||
)
|
|
||||||
return data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authAPI = {
|
export const authAPI = {
|
||||||
@@ -498,6 +553,13 @@ export const authAPI = {
|
|||||||
resetPassword,
|
resetPassword,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
revokeAllSessions,
|
revokeAllSessions,
|
||||||
|
getPendingOAuthBindLoginKind,
|
||||||
|
isPendingOAuthCreateAccountRequired,
|
||||||
|
hasPendingOAuthSuggestedProfile,
|
||||||
|
completePendingOAuthBindLogin,
|
||||||
|
createPendingLinuxDoOAuthAccount,
|
||||||
|
createPendingOIDCOAuthAccount,
|
||||||
|
createPendingWeChatOAuthAccount,
|
||||||
exchangePendingOAuthCompletion,
|
exchangePendingOAuthCompletion,
|
||||||
completeLinuxDoOAuthRegistration,
|
completeLinuxDoOAuthRegistration,
|
||||||
completeOIDCOAuthRegistration,
|
completeOIDCOAuthRegistration,
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
return {
|
return {
|
||||||
registration_enabled: false,
|
registration_enabled: false,
|
||||||
email_verify_enabled: false,
|
email_verify_enabled: false,
|
||||||
|
force_email_on_third_party_signup: false,
|
||||||
registration_email_suffix_whitelist: [],
|
registration_email_suffix_whitelist: [],
|
||||||
promo_code_enabled: true,
|
promo_code_enabled: true,
|
||||||
password_reset_enabled: false,
|
password_reset_enabled: false,
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ export interface CustomEndpoint {
|
|||||||
export interface PublicSettings {
|
export interface PublicSettings {
|
||||||
registration_enabled: boolean
|
registration_enabled: boolean
|
||||||
email_verify_enabled: boolean
|
email_verify_enabled: boolean
|
||||||
|
force_email_on_third_party_signup: boolean
|
||||||
registration_email_suffix_whitelist: string[]
|
registration_email_suffix_whitelist: string[]
|
||||||
promo_code_enabled: boolean
|
promo_code_enabled: boolean
|
||||||
password_reset_enabled: boolean
|
password_reset_enabled: boolean
|
||||||
|
|||||||
@@ -11,7 +11,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<transition name="fade">
|
<transition name="fade">
|
||||||
<div v-if="needsInvitation || needsAdoptionConfirmation" class="space-y-4">
|
<div
|
||||||
|
v-if="needsInvitation || needsAdoptionConfirmation || needsCreateAccount || needsBindLogin"
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
v-if="adoptionRequired && (suggestedDisplayName || suggestedAvatarUrl)"
|
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"
|
class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60"
|
||||||
@@ -99,6 +102,90 @@
|
|||||||
{{ isSubmitting ? t('common.processing') : 'Continue' }}
|
{{ isSubmitting ? t('common.processing') : 'Continue' }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="needsCreateAccount">
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
Enter an email address to create your account and continue.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<input
|
||||||
|
v-model="pendingAccountEmail"
|
||||||
|
data-testid="linuxdo-create-account-email"
|
||||||
|
type="email"
|
||||||
|
class="input w-full"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@keyup.enter="handleCreateAccount"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
data-testid="linuxdo-create-account-submit"
|
||||||
|
class="btn btn-primary w-full"
|
||||||
|
:disabled="isSubmitting || !pendingAccountEmail.trim()"
|
||||||
|
@click="handleCreateAccount"
|
||||||
|
>
|
||||||
|
{{ isSubmitting ? t('common.processing') : 'Create account' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary w-full"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@click="switchToBindLoginMode"
|
||||||
|
>
|
||||||
|
I already have an account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<transition name="fade">
|
||||||
|
<p v-if="accountActionError" class="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{{ accountActionError }}
|
||||||
|
</p>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="needsBindLogin">
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
Log in to an existing account to bind this LinuxDo sign-in.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<input
|
||||||
|
v-model="bindLoginEmail"
|
||||||
|
data-testid="linuxdo-bind-login-email"
|
||||||
|
type="email"
|
||||||
|
class="input w-full"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@keyup.enter="handleBindLogin"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="bindLoginPassword"
|
||||||
|
data-testid="linuxdo-bind-login-password"
|
||||||
|
type="password"
|
||||||
|
class="input w-full"
|
||||||
|
placeholder="Password"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@keyup.enter="handleBindLogin"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
data-testid="linuxdo-bind-login-submit"
|
||||||
|
class="btn btn-primary w-full"
|
||||||
|
:disabled="isSubmitting || !bindLoginEmail.trim() || !bindLoginPassword"
|
||||||
|
@click="handleBindLogin"
|
||||||
|
>
|
||||||
|
{{ isSubmitting ? t('common.processing') : 'Log in and bind' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canReturnToCreateAccount"
|
||||||
|
class="btn btn-secondary w-full"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@click="switchToCreateAccountMode"
|
||||||
|
>
|
||||||
|
Use a different email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<transition name="fade">
|
||||||
|
<p v-if="accountActionError" class="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{{ accountActionError }}
|
||||||
|
</p>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
@@ -127,11 +214,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { AuthLayout } from '@/components/layout'
|
import { AuthLayout } from '@/components/layout'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
|
import { apiClient } from '@/api/client'
|
||||||
import { useAuthStore, useAppStore } from '@/stores'
|
import { useAuthStore, useAppStore } from '@/stores'
|
||||||
import {
|
import {
|
||||||
completeLinuxDoOAuthRegistration,
|
completeLinuxDoOAuthRegistration,
|
||||||
@@ -165,8 +253,23 @@ const suggestedAvatarUrl = ref('')
|
|||||||
const adoptDisplayName = ref(true)
|
const adoptDisplayName = ref(true)
|
||||||
const adoptAvatar = ref(true)
|
const adoptAvatar = ref(true)
|
||||||
const needsAdoptionConfirmation = ref(false)
|
const needsAdoptionConfirmation = ref(false)
|
||||||
|
const pendingAccountAction = ref<'none' | 'create_account' | 'bind_login'>('none')
|
||||||
|
const pendingAccountEmail = ref('')
|
||||||
|
const bindLoginEmail = ref('')
|
||||||
|
const bindLoginPassword = ref('')
|
||||||
|
const accountActionError = ref('')
|
||||||
|
const canReturnToCreateAccount = ref(false)
|
||||||
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
|
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
|
||||||
|
|
||||||
|
const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account')
|
||||||
|
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
|
||||||
|
|
||||||
|
type LinuxDoPendingActionResponse = PendingOAuthExchangeResponse & {
|
||||||
|
step?: string
|
||||||
|
email?: string
|
||||||
|
resolved_email?: string
|
||||||
|
}
|
||||||
|
|
||||||
function parseFragmentParams(): URLSearchParams {
|
function parseFragmentParams(): URLSearchParams {
|
||||||
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
||||||
const hash = raw.startsWith('#') ? raw.slice(1) : raw
|
const hash = raw.startsWith('#') ? raw.slice(1) : raw
|
||||||
@@ -189,6 +292,17 @@ function currentAdoptionDecision(): OAuthAdoptionDecision {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeAdoptionDecision(decision: OAuthAdoptionDecision): Record<string, boolean> {
|
||||||
|
const payload: Record<string, boolean> = {}
|
||||||
|
if (typeof decision.adoptDisplayName === 'boolean') {
|
||||||
|
payload.adopt_display_name = decision.adoptDisplayName
|
||||||
|
}
|
||||||
|
if (typeof decision.adoptAvatar === 'boolean') {
|
||||||
|
payload.adopt_avatar = decision.adoptAvatar
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
function applyAdoptionSuggestionState(completion: {
|
function applyAdoptionSuggestionState(completion: {
|
||||||
adoption_required?: boolean
|
adoption_required?: boolean
|
||||||
suggested_display_name?: string
|
suggested_display_name?: string
|
||||||
@@ -213,6 +327,62 @@ function hasSuggestedProfile(completion: {
|
|||||||
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
|
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractPendingAccountEmail(completion: LinuxDoPendingActionResponse): string {
|
||||||
|
return (completion.email || completion.resolved_email || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePendingAccountAction(completion: LinuxDoPendingActionResponse): 'none' | 'create_account' | 'bind_login' {
|
||||||
|
const raw = (completion.step || completion.error || '').trim().toLowerCase()
|
||||||
|
if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') {
|
||||||
|
return 'create_account'
|
||||||
|
}
|
||||||
|
if (raw === 'bind_login_required' || raw === 'bind_login') {
|
||||||
|
return 'bind_login'
|
||||||
|
}
|
||||||
|
return 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPendingAccountAction(completion: LinuxDoPendingActionResponse) {
|
||||||
|
const action = resolvePendingAccountAction(completion)
|
||||||
|
pendingAccountAction.value = action
|
||||||
|
accountActionError.value = ''
|
||||||
|
|
||||||
|
const email = extractPendingAccountEmail(completion)
|
||||||
|
if (action === 'create_account') {
|
||||||
|
pendingAccountEmail.value = email
|
||||||
|
canReturnToCreateAccount.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'bind_login') {
|
||||||
|
bindLoginEmail.value = email
|
||||||
|
bindLoginPassword.value = ''
|
||||||
|
canReturnToCreateAccount.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
canReturnToCreateAccount.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToBindLoginMode() {
|
||||||
|
pendingAccountAction.value = 'bind_login'
|
||||||
|
bindLoginEmail.value = bindLoginEmail.value.trim() || pendingAccountEmail.value.trim()
|
||||||
|
bindLoginPassword.value = ''
|
||||||
|
accountActionError.value = ''
|
||||||
|
canReturnToCreateAccount.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToCreateAccountMode() {
|
||||||
|
pendingAccountAction.value = 'create_account'
|
||||||
|
pendingAccountEmail.value = pendingAccountEmail.value.trim() || bindLoginEmail.value.trim()
|
||||||
|
accountActionError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
const err = error as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||||
|
return err.response?.data?.detail || err.response?.data?.message || err.message || fallback
|
||||||
|
}
|
||||||
|
|
||||||
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
|
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
|
||||||
if (getOAuthCompletionKind(completion) === 'bind') {
|
if (getOAuthCompletionKind(completion) === 'bind') {
|
||||||
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
|
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
|
||||||
@@ -231,6 +401,29 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
|
|||||||
await router.replace(redirect)
|
await router.replace(redirect)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function finalizePendingAccountResponse(completion: LinuxDoPendingActionResponse) {
|
||||||
|
applyAdoptionSuggestionState(completion)
|
||||||
|
|
||||||
|
if (completion.error === 'invitation_required') {
|
||||||
|
pendingAccountAction.value = 'none'
|
||||||
|
needsInvitation.value = true
|
||||||
|
needsAdoptionConfirmation.value = false
|
||||||
|
isProcessing.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPendingAccountAction(completion)
|
||||||
|
if (pendingAccountAction.value !== 'none') {
|
||||||
|
needsInvitation.value = false
|
||||||
|
needsAdoptionConfirmation.value = false
|
||||||
|
isProcessing.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirect = sanitizeRedirectPath(completion.redirect || redirectTo.value)
|
||||||
|
await finalizeCompletion(completion, redirect)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmitInvitation() {
|
async function handleSubmitInvitation() {
|
||||||
invitationError.value = ''
|
invitationError.value = ''
|
||||||
if (!invitationCode.value.trim()) return
|
if (!invitationCode.value.trim()) return
|
||||||
@@ -260,12 +453,7 @@ async function handleContinueLogin() {
|
|||||||
const completion = await exchangePendingOAuthCompletion(currentAdoptionDecision())
|
const completion = await exchangePendingOAuthCompletion(currentAdoptionDecision())
|
||||||
await finalizeCompletion(completion, redirectTo.value)
|
await finalizeCompletion(completion, redirectTo.value)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||||
errorMessage.value =
|
|
||||||
err.response?.data?.detail ||
|
|
||||||
err.response?.data?.message ||
|
|
||||||
err.message ||
|
|
||||||
t('auth.loginFailed')
|
|
||||||
appStore.showError(errorMessage.value)
|
appStore.showError(errorMessage.value)
|
||||||
needsAdoptionConfirmation.value = false
|
needsAdoptionConfirmation.value = false
|
||||||
} finally {
|
} finally {
|
||||||
@@ -273,6 +461,46 @@ async function handleContinueLogin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCreateAccount() {
|
||||||
|
accountActionError.value = ''
|
||||||
|
const email = pendingAccountEmail.value.trim()
|
||||||
|
if (!email) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.post<LinuxDoPendingActionResponse>('/auth/oauth/pending/create-account', {
|
||||||
|
email,
|
||||||
|
...serializeAdoptionDecision(currentAdoptionDecision())
|
||||||
|
})
|
||||||
|
await finalizePendingAccountResponse(data)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBindLogin() {
|
||||||
|
accountActionError.value = ''
|
||||||
|
const email = bindLoginEmail.value.trim()
|
||||||
|
const password = bindLoginPassword.value
|
||||||
|
if (!email || !password) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.post<LinuxDoPendingActionResponse>('/auth/oauth/pending/bind-login', {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
...serializeAdoptionDecision(currentAdoptionDecision())
|
||||||
|
})
|
||||||
|
await finalizePendingAccountResponse(data)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const params = parseFragmentParams()
|
const params = parseFragmentParams()
|
||||||
const error = params.get('error')
|
const error = params.get('error')
|
||||||
@@ -299,6 +527,12 @@ onMounted(async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyPendingAccountAction(completion as LinuxDoPendingActionResponse)
|
||||||
|
if (pendingAccountAction.value !== 'none') {
|
||||||
|
isProcessing.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (adoptionRequired.value && hasSuggestedProfile(completion)) {
|
if (adoptionRequired.value && hasSuggestedProfile(completion)) {
|
||||||
needsAdoptionConfirmation.value = true
|
needsAdoptionConfirmation.value = true
|
||||||
isProcessing.value = false
|
isProcessing.value = false
|
||||||
@@ -307,12 +541,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
await finalizeCompletion(completion, redirect)
|
await finalizeCompletion(completion, redirect)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||||
errorMessage.value =
|
|
||||||
err.response?.data?.detail ||
|
|
||||||
err.response?.data?.message ||
|
|
||||||
err.message ||
|
|
||||||
t('auth.loginFailed')
|
|
||||||
appStore.showError(errorMessage.value)
|
appStore.showError(errorMessage.value)
|
||||||
isProcessing.value = false
|
isProcessing.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<transition name="fade">
|
<transition name="fade">
|
||||||
<div v-if="needsInvitation || needsAdoptionConfirmation" class="space-y-4">
|
<div
|
||||||
|
v-if="
|
||||||
|
needsInvitation ||
|
||||||
|
needsEmailCollection ||
|
||||||
|
needsExistingAccountBinding ||
|
||||||
|
needsAdoptionConfirmation
|
||||||
|
"
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
v-if="adoptionRequired && (suggestedDisplayName || suggestedAvatarUrl)"
|
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"
|
class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60"
|
||||||
@@ -100,6 +108,39 @@
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="needsEmailCollection">
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
Continue with email to finish setting up your {{ providerName }} sign-in.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
v-model="pendingEmail"
|
||||||
|
type="email"
|
||||||
|
class="input w-full"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@keyup.enter="handleContinueWithEmail"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary w-full"
|
||||||
|
:disabled="isSubmitting || !pendingEmail.trim()"
|
||||||
|
@click="handleContinueWithEmail"
|
||||||
|
>
|
||||||
|
Continue with email
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="needsExistingAccountBinding">
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
Sign in to bind {{ providerName }} to the existing account for
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white">{{ pendingEmail }}</span>.
|
||||||
|
</p>
|
||||||
|
<button class="btn btn-primary w-full" :disabled="isSubmitting" @click="handleContinueToLogin">
|
||||||
|
Sign in to bind
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-else-if="needsAdoptionConfirmation">
|
<template v-else-if="needsAdoptionConfirmation">
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
Review the {{ providerName }} profile details before continuing.
|
Review the {{ providerName }} profile details before continuing.
|
||||||
@@ -174,9 +215,22 @@ const suggestedDisplayName = ref('')
|
|||||||
const suggestedAvatarUrl = ref('')
|
const suggestedAvatarUrl = ref('')
|
||||||
const adoptDisplayName = ref(true)
|
const adoptDisplayName = ref(true)
|
||||||
const adoptAvatar = ref(true)
|
const adoptAvatar = ref(true)
|
||||||
|
const pendingEmail = ref('')
|
||||||
|
const needsEmailCollection = ref(false)
|
||||||
|
const needsExistingAccountBinding = ref(false)
|
||||||
const needsAdoptionConfirmation = ref(false)
|
const needsAdoptionConfirmation = ref(false)
|
||||||
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
|
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
|
||||||
|
|
||||||
|
type PendingOidcCompletion = PendingOAuthExchangeResponse & {
|
||||||
|
step?: string
|
||||||
|
pending_email?: string
|
||||||
|
resolved_email?: string
|
||||||
|
existing_account_email?: string
|
||||||
|
email?: string
|
||||||
|
provider_fallback?: string
|
||||||
|
intent?: string
|
||||||
|
}
|
||||||
|
|
||||||
function parseFragmentParams(): URLSearchParams {
|
function parseFragmentParams(): URLSearchParams {
|
||||||
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
||||||
const hash = raw.startsWith('#') ? raw.slice(1) : raw
|
const hash = raw.startsWith('#') ? raw.slice(1) : raw
|
||||||
@@ -204,6 +258,34 @@ async function loadProviderName() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizedPendingState(value: string | null | undefined): string {
|
||||||
|
return value?.trim().toLowerCase() || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePendingEmail(completion: PendingOidcCompletion): string {
|
||||||
|
return (
|
||||||
|
completion.pending_email ||
|
||||||
|
completion.existing_account_email ||
|
||||||
|
completion.resolved_email ||
|
||||||
|
completion.email ||
|
||||||
|
''
|
||||||
|
).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiresEmailCollection(completion: PendingOidcCompletion): boolean {
|
||||||
|
const state = normalizedPendingState(completion.step || completion.error)
|
||||||
|
return state === 'email_required'
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiresExistingAccountBinding(completion: PendingOidcCompletion): boolean {
|
||||||
|
const state = normalizedPendingState(completion.step || completion.error || completion.intent)
|
||||||
|
return (
|
||||||
|
state === 'existing_account_binding_required' ||
|
||||||
|
state === 'existing_account_required' ||
|
||||||
|
state === 'adopt_existing_user_by_email'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function currentAdoptionDecision(): OAuthAdoptionDecision {
|
function currentAdoptionDecision(): OAuthAdoptionDecision {
|
||||||
return {
|
return {
|
||||||
adoptDisplayName: adoptDisplayName.value,
|
adoptDisplayName: adoptDisplayName.value,
|
||||||
@@ -295,6 +377,35 @@ async function handleContinueLogin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleContinueWithEmail() {
|
||||||
|
const email = pendingEmail.value.trim()
|
||||||
|
if (!email) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await router.replace({
|
||||||
|
path: '/register',
|
||||||
|
query: {
|
||||||
|
email,
|
||||||
|
redirect: redirectTo.value,
|
||||||
|
provider: providerName.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleContinueToLogin() {
|
||||||
|
const email = pendingEmail.value.trim()
|
||||||
|
|
||||||
|
await router.replace({
|
||||||
|
path: '/login',
|
||||||
|
query: {
|
||||||
|
email,
|
||||||
|
redirect: redirectTo.value,
|
||||||
|
provider: providerName.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
void loadProviderName()
|
void loadProviderName()
|
||||||
|
|
||||||
@@ -310,12 +421,13 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const completion = await exchangePendingOAuthCompletion()
|
const completion = await exchangePendingOAuthCompletion() as PendingOidcCompletion
|
||||||
const redirect = sanitizeRedirectPath(
|
const redirect = sanitizeRedirectPath(
|
||||||
completion.redirect || (route.query.redirect as string | undefined) || '/dashboard'
|
completion.redirect || (route.query.redirect as string | undefined) || '/dashboard'
|
||||||
)
|
)
|
||||||
applyAdoptionSuggestionState(completion)
|
applyAdoptionSuggestionState(completion)
|
||||||
redirectTo.value = redirect
|
redirectTo.value = redirect
|
||||||
|
pendingEmail.value = resolvePendingEmail(completion)
|
||||||
|
|
||||||
if (completion.error === 'invitation_required') {
|
if (completion.error === 'invitation_required') {
|
||||||
needsInvitation.value = true
|
needsInvitation.value = true
|
||||||
@@ -323,6 +435,18 @@ onMounted(async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requiresEmailCollection(completion)) {
|
||||||
|
needsEmailCollection.value = true
|
||||||
|
isProcessing.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiresExistingAccountBinding(completion)) {
|
||||||
|
needsExistingAccountBinding.value = true
|
||||||
|
isProcessing.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (adoptionRequired.value && hasSuggestedProfile(completion)) {
|
if (adoptionRequired.value && hasSuggestedProfile(completion)) {
|
||||||
needsAdoptionConfirmation.value = true
|
needsAdoptionConfirmation.value = true
|
||||||
isProcessing.value = false
|
isProcessing.value = false
|
||||||
|
|||||||
@@ -97,6 +97,40 @@
|
|||||||
: t('auth.oidc.completeRegistration')
|
: t('auth.oidc.completeRegistration')
|
||||||
}}
|
}}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
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">
|
||||||
|
{{ t('auth.alreadyHaveAccount') }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||||
|
Sign in to an existing account, then bind this WeChat identity to it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-model="existingAccountEmail"
|
||||||
|
data-testid="existing-account-email"
|
||||||
|
type="email"
|
||||||
|
class="input w-full"
|
||||||
|
:placeholder="t('auth.emailPlaceholder')"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
data-testid="existing-account-submit"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary w-full"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@click="handleExistingAccountBinding"
|
||||||
|
>
|
||||||
|
{{ t('auth.signIn') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="needsAdoptionConfirmation">
|
<template v-else-if="needsAdoptionConfirmation">
|
||||||
@@ -144,8 +178,10 @@ import { useAuthStore, useAppStore } from '@/stores'
|
|||||||
import {
|
import {
|
||||||
completeWeChatOAuthRegistration,
|
completeWeChatOAuthRegistration,
|
||||||
exchangePendingOAuthCompletion,
|
exchangePendingOAuthCompletion,
|
||||||
|
getAuthToken,
|
||||||
getOAuthCompletionKind,
|
getOAuthCompletionKind,
|
||||||
isOAuthLoginCompletion,
|
isOAuthLoginCompletion,
|
||||||
|
prepareOAuthBindAccessTokenCookie,
|
||||||
persistOAuthTokenContext,
|
persistOAuthTokenContext,
|
||||||
type OAuthAdoptionDecision,
|
type OAuthAdoptionDecision,
|
||||||
type PendingOAuthExchangeResponse
|
type PendingOAuthExchangeResponse
|
||||||
@@ -168,6 +204,7 @@ const redirectTo = ref('/dashboard')
|
|||||||
const adoptionRequired = ref(false)
|
const adoptionRequired = ref(false)
|
||||||
const suggestedDisplayName = ref('')
|
const suggestedDisplayName = ref('')
|
||||||
const suggestedAvatarUrl = ref('')
|
const suggestedAvatarUrl = ref('')
|
||||||
|
const existingAccountEmail = ref('')
|
||||||
const adoptDisplayName = ref(true)
|
const adoptDisplayName = ref(true)
|
||||||
const adoptAvatar = ref(true)
|
const adoptAvatar = ref(true)
|
||||||
const needsAdoptionConfirmation = ref(false)
|
const needsAdoptionConfirmation = ref(false)
|
||||||
@@ -190,6 +227,50 @@ function sanitizeRedirectPath(path: string | null | undefined): string {
|
|||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveWeChatOAuthMode(): 'open' | 'mp' {
|
||||||
|
if (typeof navigator === 'undefined') {
|
||||||
|
return 'open'
|
||||||
|
}
|
||||||
|
return /MicroMessenger/i.test(navigator.userAgent) ? 'mp' : 'open'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRedirectTarget(): string {
|
||||||
|
return sanitizeRedirectPath(
|
||||||
|
(route.query.redirect as string | undefined) || redirectTo.value || '/dashboard'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWeChatStartURL(intent: 'bind_current_user' | 'adopt_existing_user_by_email'): string {
|
||||||
|
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
|
||||||
|
const normalized = apiBase.replace(/\/$/, '')
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
mode: resolveWeChatOAuthMode(),
|
||||||
|
redirect: resolveRedirectTarget(),
|
||||||
|
intent,
|
||||||
|
})
|
||||||
|
|
||||||
|
const email = existingAccountEmail.value.trim()
|
||||||
|
if (email) {
|
||||||
|
params.set('email', email)
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${normalized}/auth/oauth/wechat/start?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExistingAccountResumePath(): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
wechat_bind_existing: '1',
|
||||||
|
redirect: resolveRedirectTarget(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const email = existingAccountEmail.value.trim()
|
||||||
|
if (email) {
|
||||||
|
params.set('email', email)
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/auth/wechat/callback?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
function currentAdoptionDecision(): OAuthAdoptionDecision {
|
function currentAdoptionDecision(): OAuthAdoptionDecision {
|
||||||
return {
|
return {
|
||||||
adoptDisplayName: adoptDisplayName.value,
|
adoptDisplayName: adoptDisplayName.value,
|
||||||
@@ -197,6 +278,23 @@ function currentAdoptionDecision(): OAuthAdoptionDecision {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleExistingAccountBinding() {
|
||||||
|
if (getAuthToken()) {
|
||||||
|
prepareOAuthBindAccessTokenCookie()
|
||||||
|
window.location.href = resolveWeChatStartURL('bind_current_user')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
redirect: buildExistingAccountResumePath(),
|
||||||
|
})
|
||||||
|
const email = existingAccountEmail.value.trim()
|
||||||
|
if (email) {
|
||||||
|
params.set('email', email)
|
||||||
|
}
|
||||||
|
await router.replace(`/login?${params.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
function applyAdoptionSuggestionState(completion: PendingOAuthExchangeResponse) {
|
function applyAdoptionSuggestionState(completion: PendingOAuthExchangeResponse) {
|
||||||
adoptionRequired.value = completion.adoption_required === true
|
adoptionRequired.value = completion.adoption_required === true
|
||||||
suggestedDisplayName.value = completion.suggested_display_name || ''
|
suggestedDisplayName.value = completion.suggested_display_name || ''
|
||||||
@@ -275,6 +373,16 @@ async function handleContinueLogin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
if (typeof route.query.email === 'string') {
|
||||||
|
existingAccountEmail.value = route.query.email
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.query.wechat_bind_existing === '1' && getAuthToken()) {
|
||||||
|
prepareOAuthBindAccessTokenCookie()
|
||||||
|
window.location.href = resolveWeChatStartURL('bind_current_user')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const params = parseFragmentParams()
|
const params = parseFragmentParams()
|
||||||
const error = params.get('error')
|
const error = params.get('error')
|
||||||
const errorDesc = params.get('error_description') || params.get('error_message') || ''
|
const errorDesc = params.get('error_description') || params.get('error_message') || ''
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const showError = vi.fn()
|
|||||||
const setToken = vi.fn()
|
const setToken = vi.fn()
|
||||||
const exchangePendingOAuthCompletion = vi.fn()
|
const exchangePendingOAuthCompletion = vi.fn()
|
||||||
const completeLinuxDoOAuthRegistration = vi.fn()
|
const completeLinuxDoOAuthRegistration = vi.fn()
|
||||||
|
const apiClientPost = vi.fn()
|
||||||
|
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', () => ({
|
||||||
useRoute: () => ({
|
useRoute: () => ({
|
||||||
@@ -39,6 +40,12 @@ vi.mock('@/stores', () => ({
|
|||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
apiClient: {
|
||||||
|
post: (...args: any[]) => apiClientPost(...args)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/api/auth', async () => {
|
vi.mock('@/api/auth', async () => {
|
||||||
const actual = await vi.importActual<typeof import('@/api/auth')>('@/api/auth')
|
const actual = await vi.importActual<typeof import('@/api/auth')>('@/api/auth')
|
||||||
return {
|
return {
|
||||||
@@ -56,6 +63,7 @@ describe('LinuxDoCallbackView', () => {
|
|||||||
setToken.mockReset()
|
setToken.mockReset()
|
||||||
exchangePendingOAuthCompletion.mockReset()
|
exchangePendingOAuthCompletion.mockReset()
|
||||||
completeLinuxDoOAuthRegistration.mockReset()
|
completeLinuxDoOAuthRegistration.mockReset()
|
||||||
|
apiClientPost.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not send adoption decisions during the initial exchange', async () => {
|
it('does not send adoption decisions during the initial exchange', async () => {
|
||||||
@@ -239,4 +247,101 @@ describe('LinuxDoCallbackView', () => {
|
|||||||
adoptAvatar: true
|
adoptAvatar: true
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('collects email for pending oauth account creation and submits adoption decisions', async () => {
|
||||||
|
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||||
|
error: 'email_required',
|
||||||
|
redirect: '/welcome',
|
||||||
|
adoption_required: true,
|
||||||
|
suggested_display_name: 'LinuxDo Nick',
|
||||||
|
suggested_avatar_url: 'https://cdn.example/linuxdo.png'
|
||||||
|
})
|
||||||
|
apiClientPost.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
access_token: 'new-access-token',
|
||||||
|
refresh_token: 'new-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()
|
||||||
|
|
||||||
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||||
|
expect(checkboxes).toHaveLength(2)
|
||||||
|
await checkboxes[1].setValue(false)
|
||||||
|
await wrapper.get('[data-testid="linuxdo-create-account-email"]').setValue(' new@example.com ')
|
||||||
|
await wrapper.get('[data-testid="linuxdo-create-account-submit"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(apiClientPost).toHaveBeenCalledWith('/auth/oauth/pending/create-account', {
|
||||||
|
email: 'new@example.com',
|
||||||
|
adopt_display_name: true,
|
||||||
|
adopt_avatar: false
|
||||||
|
})
|
||||||
|
expect(setToken).toHaveBeenCalledWith('new-access-token')
|
||||||
|
expect(replace).toHaveBeenCalledWith('/welcome')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows bind-login form for existing account binding and submits credentials with adoption decisions', async () => {
|
||||||
|
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||||
|
error: 'bind_login_required',
|
||||||
|
redirect: '/profile/security',
|
||||||
|
email: 'existing@example.com',
|
||||||
|
adoption_required: true,
|
||||||
|
suggested_display_name: 'LinuxDo Nick',
|
||||||
|
suggested_avatar_url: 'https://cdn.example/linuxdo.png'
|
||||||
|
})
|
||||||
|
apiClientPost.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
access_token: 'bind-access-token',
|
||||||
|
refresh_token: 'bind-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()
|
||||||
|
|
||||||
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||||
|
expect(checkboxes).toHaveLength(2)
|
||||||
|
await checkboxes[0].setValue(false)
|
||||||
|
await wrapper.get('[data-testid="linuxdo-bind-login-email"]').setValue('existing@example.com')
|
||||||
|
await wrapper.get('[data-testid="linuxdo-bind-login-password"]').setValue('secret-password')
|
||||||
|
await wrapper.get('[data-testid="linuxdo-bind-login-submit"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(apiClientPost).toHaveBeenCalledWith('/auth/oauth/pending/bind-login', {
|
||||||
|
email: 'existing@example.com',
|
||||||
|
password: 'secret-password',
|
||||||
|
adopt_display_name: false,
|
||||||
|
adopt_avatar: true
|
||||||
|
})
|
||||||
|
expect(setToken).toHaveBeenCalledWith('bind-access-token')
|
||||||
|
expect(replace).toHaveBeenCalledWith('/profile/security')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -184,6 +184,77 @@ describe('OidcCallbackView', () => {
|
|||||||
expect(replace).toHaveBeenCalledWith('/profile')
|
expect(replace).toHaveBeenCalledWith('/profile')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders pending email collection ui and routes to register with the entered email', async () => {
|
||||||
|
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||||
|
error: 'email_required',
|
||||||
|
redirect: '/profile',
|
||||||
|
provider_fallback: 'ExampleID'
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(OidcCallbackView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AuthLayout: { template: '<div><slot /></div>' },
|
||||||
|
Icon: true,
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
transition: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(setToken).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.text()).toContain('Continue with email')
|
||||||
|
|
||||||
|
await wrapper.get('input[type="email"]').setValue('alice@example.com')
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
|
||||||
|
expect(replace).toHaveBeenCalledWith({
|
||||||
|
path: '/register',
|
||||||
|
query: {
|
||||||
|
email: 'alice@example.com',
|
||||||
|
redirect: '/profile',
|
||||||
|
provider: 'ExampleID'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders existing-account binding ui and routes to login', async () => {
|
||||||
|
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||||
|
error: 'existing_account_binding_required',
|
||||||
|
redirect: '/profile',
|
||||||
|
existing_account_email: 'alice@example.com'
|
||||||
|
})
|
||||||
|
|
||||||
|
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('alice@example.com')
|
||||||
|
expect(wrapper.text()).toContain('Sign in to bind')
|
||||||
|
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
|
||||||
|
expect(replace).toHaveBeenCalledWith({
|
||||||
|
path: '/login',
|
||||||
|
query: {
|
||||||
|
email: 'alice@example.com',
|
||||||
|
redirect: '/profile',
|
||||||
|
provider: 'ExampleID'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('renders adoption choices for invitation flow and submits the selected values', async () => {
|
it('renders adoption choices for invitation flow and submits the selected values', async () => {
|
||||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||||
error: 'invitation_required',
|
error: 'invitation_required',
|
||||||
|
|||||||
@@ -5,14 +5,19 @@ import WechatCallbackView from '@/views/auth/WechatCallbackView.vue'
|
|||||||
const {
|
const {
|
||||||
exchangePendingOAuthCompletionMock,
|
exchangePendingOAuthCompletionMock,
|
||||||
completeWeChatOAuthRegistrationMock,
|
completeWeChatOAuthRegistrationMock,
|
||||||
|
prepareOAuthBindAccessTokenCookieMock,
|
||||||
|
getAuthTokenMock,
|
||||||
replaceMock,
|
replaceMock,
|
||||||
setTokenMock,
|
setTokenMock,
|
||||||
showSuccessMock,
|
showSuccessMock,
|
||||||
showErrorMock,
|
showErrorMock,
|
||||||
routeState,
|
routeState,
|
||||||
|
locationState,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
exchangePendingOAuthCompletionMock: vi.fn(),
|
exchangePendingOAuthCompletionMock: vi.fn(),
|
||||||
completeWeChatOAuthRegistrationMock: vi.fn(),
|
completeWeChatOAuthRegistrationMock: vi.fn(),
|
||||||
|
prepareOAuthBindAccessTokenCookieMock: vi.fn(),
|
||||||
|
getAuthTokenMock: vi.fn(),
|
||||||
replaceMock: vi.fn(),
|
replaceMock: vi.fn(),
|
||||||
setTokenMock: vi.fn(),
|
setTokenMock: vi.fn(),
|
||||||
showSuccessMock: vi.fn(),
|
showSuccessMock: vi.fn(),
|
||||||
@@ -20,6 +25,14 @@ const {
|
|||||||
routeState: {
|
routeState: {
|
||||||
query: {} as Record<string, unknown>,
|
query: {} as Record<string, unknown>,
|
||||||
},
|
},
|
||||||
|
locationState: {
|
||||||
|
current: {
|
||||||
|
href: 'http://localhost/auth/wechat/callback',
|
||||||
|
hash: '',
|
||||||
|
search: '',
|
||||||
|
pathname: '/auth/wechat/callback'
|
||||||
|
} as { href: string; hash: string; search: string; pathname: string },
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', () => ({
|
||||||
@@ -94,6 +107,8 @@ vi.mock('@/api/auth', async () => {
|
|||||||
...actual,
|
...actual,
|
||||||
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletionMock(...args),
|
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletionMock(...args),
|
||||||
completeWeChatOAuthRegistration: (...args: any[]) => completeWeChatOAuthRegistrationMock(...args),
|
completeWeChatOAuthRegistration: (...args: any[]) => completeWeChatOAuthRegistrationMock(...args),
|
||||||
|
prepareOAuthBindAccessTokenCookie: (...args: any[]) => prepareOAuthBindAccessTokenCookieMock(...args),
|
||||||
|
getAuthToken: (...args: any[]) => getAuthTokenMock(...args),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -105,8 +120,24 @@ describe('WechatCallbackView', () => {
|
|||||||
setTokenMock.mockReset()
|
setTokenMock.mockReset()
|
||||||
showSuccessMock.mockReset()
|
showSuccessMock.mockReset()
|
||||||
showErrorMock.mockReset()
|
showErrorMock.mockReset()
|
||||||
|
prepareOAuthBindAccessTokenCookieMock.mockReset()
|
||||||
|
getAuthTokenMock.mockReset()
|
||||||
routeState.query = {}
|
routeState.query = {}
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
|
locationState.current = {
|
||||||
|
href: 'http://localhost/auth/wechat/callback',
|
||||||
|
hash: '',
|
||||||
|
search: '',
|
||||||
|
pathname: '/auth/wechat/callback'
|
||||||
|
}
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
configurable: true,
|
||||||
|
value: locationState.current,
|
||||||
|
})
|
||||||
|
Object.defineProperty(window.navigator, 'userAgent', {
|
||||||
|
configurable: true,
|
||||||
|
value: 'Mozilla/5.0',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not send adoption decisions during the initial exchange', async () => {
|
it('does not send adoption decisions during the initial exchange', async () => {
|
||||||
@@ -269,4 +300,61 @@ describe('WechatCallbackView', () => {
|
|||||||
expect(setTokenMock).toHaveBeenCalledWith('wechat-invite-token')
|
expect(setTokenMock).toHaveBeenCalledWith('wechat-invite-token')
|
||||||
expect(replaceMock).toHaveBeenCalledWith('/subscriptions')
|
expect(replaceMock).toHaveBeenCalledWith('/subscriptions')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('offers existing-account email collection during invitation flow', async () => {
|
||||||
|
exchangePendingOAuthCompletionMock.mockResolvedValue({
|
||||||
|
error: 'invitation_required',
|
||||||
|
redirect: '/usage',
|
||||||
|
})
|
||||||
|
getAuthTokenMock.mockReturnValue(null)
|
||||||
|
|
||||||
|
const wrapper = mount(WechatCallbackView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AuthLayout: { template: '<div><slot /></div>' },
|
||||||
|
Icon: true,
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
transition: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const emailInput = wrapper.get('[data-testid="existing-account-email"]')
|
||||||
|
await emailInput.setValue('user@example.com')
|
||||||
|
await wrapper.get('[data-testid="existing-account-submit"]').trigger('click')
|
||||||
|
|
||||||
|
expect(replaceMock).toHaveBeenCalledTimes(1)
|
||||||
|
expect(replaceMock.mock.calls[0]?.[0]).toContain('/login?')
|
||||||
|
expect(replaceMock.mock.calls[0]?.[0]).toContain('wechat_bind_existing%3D1')
|
||||||
|
expect(replaceMock.mock.calls[0]?.[0]).toContain('email=user%40example.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restarts the current-user bind flow after returning from login', async () => {
|
||||||
|
routeState.query = {
|
||||||
|
wechat_bind_existing: '1',
|
||||||
|
redirect: '/profile'
|
||||||
|
}
|
||||||
|
getAuthTokenMock.mockReturnValue('existing-auth-token')
|
||||||
|
|
||||||
|
mount(WechatCallbackView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AuthLayout: { template: '<div><slot /></div>' },
|
||||||
|
Icon: true,
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
transition: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(exchangePendingOAuthCompletionMock).not.toHaveBeenCalled()
|
||||||
|
expect(prepareOAuthBindAccessTokenCookieMock).toHaveBeenCalledTimes(1)
|
||||||
|
expect(locationState.current.href).toContain('/api/v1/auth/oauth/wechat/start?')
|
||||||
|
expect(locationState.current.href).toContain('intent=bind_current_user')
|
||||||
|
expect(locationState.current.href).toContain('redirect=%2Fprofile')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user