feat: 增加 GitHub 和 Google 邮箱快捷登录
This commit is contained in:
@@ -1,7 +1,60 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 px-4 py-10 dark:bg-dark-900">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<div class="card p-6">
|
||||
<div v-if="isProcessing" class="card p-6 text-center">
|
||||
<div class="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"></div>
|
||||
<h1 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('auth.oauth.callbackTitle') }}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ t('auth.oauth.callbackHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="needsInvitation" class="card p-6">
|
||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('auth.oidc.callbackTitle', { providerName }) }}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ t('auth.oidc.invitationRequired', { providerName }) }}
|
||||
</p>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<input
|
||||
v-model="invitationCode"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
:placeholder="t('auth.invitationCodePlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
@keyup.enter="handleSubmitInvitation"
|
||||
/>
|
||||
<p v-if="invitationError" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ invitationError }}
|
||||
</p>
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
type="button"
|
||||
:disabled="isSubmitting || !invitationCode.trim()"
|
||||
@click="handleSubmitInvitation"
|
||||
>
|
||||
{{ isSubmitting ? t('common.processing') : t('auth.oidc.completeRegistration') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="invalidCallback" class="card p-6 text-center">
|
||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('auth.oauth.invalidCallbackTitle') }}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ t('auth.oauth.invalidCallbackHint') }}
|
||||
</p>
|
||||
<button class="btn btn-primary mt-6" type="button" @click="router.replace('/login')">
|
||||
{{ t('auth.backToLogin') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="card p-6">
|
||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('auth.oauth.callbackTitle') }}
|
||||
</h1>
|
||||
@@ -56,16 +109,43 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { useAppStore, useAuthStore } from '@/stores'
|
||||
import { apiClient } from '@/api/client'
|
||||
import {
|
||||
exchangePendingOAuthCompletion,
|
||||
persistOAuthTokenContext,
|
||||
type OAuthTokenResponse
|
||||
} from '@/api/auth'
|
||||
import {
|
||||
clearAllAffiliateReferralCodes,
|
||||
loadOAuthAffiliateCode,
|
||||
oauthAffiliatePayload
|
||||
} from '@/utils/oauthAffiliate'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
const isProcessing = ref(false)
|
||||
const isSubmitting = ref(false)
|
||||
const needsInvitation = ref(false)
|
||||
const invitationCode = ref('')
|
||||
const invitationError = ref('')
|
||||
const pendingProvider = ref<'github' | 'google'>('github')
|
||||
const redirectTo = ref('/dashboard')
|
||||
const invalidCallback = ref(false)
|
||||
|
||||
type EmailOAuthPendingCompletion = Partial<OAuthTokenResponse> & {
|
||||
error?: string
|
||||
provider?: string
|
||||
redirect?: string
|
||||
}
|
||||
|
||||
const code = computed(() => (route.query.code as string) || '')
|
||||
const state = computed(() => (route.query.state as string) || '')
|
||||
@@ -77,6 +157,137 @@ const fullUrl = computed(() => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
return window.location.href
|
||||
})
|
||||
const providerName = computed(() =>
|
||||
pendingProvider.value === 'google' ? 'Google' : 'GitHub'
|
||||
)
|
||||
|
||||
function parseFragmentParams(): URLSearchParams {
|
||||
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
||||
const hash = raw.startsWith('#') ? raw.slice(1) : raw
|
||||
return new URLSearchParams(hash)
|
||||
}
|
||||
|
||||
function readTokenResponse(params: URLSearchParams): OAuthTokenResponse | null {
|
||||
const accessToken = params.get('access_token')?.trim() || ''
|
||||
if (!accessToken) return null
|
||||
|
||||
const response: OAuthTokenResponse = { access_token: accessToken }
|
||||
const refreshToken = params.get('refresh_token')?.trim() || ''
|
||||
if (refreshToken) response.refresh_token = refreshToken
|
||||
const expiresIn = Number.parseInt(params.get('expires_in')?.trim() || '', 10)
|
||||
if (Number.isFinite(expiresIn) && expiresIn > 0) response.expires_in = expiresIn
|
||||
const tokenType = params.get('token_type')?.trim() || ''
|
||||
if (tokenType) response.token_type = tokenType
|
||||
return response
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
async function finalizeTokenResponse(tokenResponse: OAuthTokenResponse, redirect: string) {
|
||||
persistOAuthTokenContext(tokenResponse)
|
||||
await authStore.setToken(tokenResponse.access_token)
|
||||
clearAllAffiliateReferralCodes()
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(sanitizeRedirectPath(redirect))
|
||||
}
|
||||
|
||||
function hasOAuthTokenResponse(value: Partial<OAuthTokenResponse>): value is OAuthTokenResponse {
|
||||
return typeof value.access_token === 'string' && value.access_token.trim() !== ''
|
||||
}
|
||||
|
||||
async function resumePendingEmailOAuth() {
|
||||
isProcessing.value = true
|
||||
try {
|
||||
const completion = await exchangePendingOAuthCompletion() as EmailOAuthPendingCompletion
|
||||
const completionRedirect = completion.redirect || '/dashboard'
|
||||
if (hasOAuthTokenResponse(completion)) {
|
||||
await finalizeTokenResponse(completion, completionRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
const provider = String(completion.provider || '').toLowerCase()
|
||||
if (provider === 'github' || provider === 'google') {
|
||||
pendingProvider.value = provider
|
||||
}
|
||||
redirectTo.value = sanitizeRedirectPath(completionRedirect)
|
||||
|
||||
if (completion.error === 'invitation_required') {
|
||||
needsInvitation.value = true
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
appStore.showError(completion.error || t('auth.loginFailed'))
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { message?: string } } }
|
||||
const message = err.response?.data?.message || err.message || t('auth.loginFailed')
|
||||
appStore.showError(message)
|
||||
invalidCallback.value = true
|
||||
} finally {
|
||||
if (!needsInvitation.value) {
|
||||
isProcessing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitInvitation() {
|
||||
invitationError.value = ''
|
||||
const code = invitationCode.value.trim()
|
||||
if (!code) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const { data } = await apiClient.post<OAuthTokenResponse>(
|
||||
`/auth/oauth/${pendingProvider.value}/complete-registration`,
|
||||
{
|
||||
invitation_code: code,
|
||||
...oauthAffiliatePayload(loadOAuthAffiliateCode())
|
||||
}
|
||||
)
|
||||
await finalizeTokenResponse(data, 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
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const params = parseFragmentParams()
|
||||
const tokenResponse = readTokenResponse(params)
|
||||
const fragmentError = params.get('error') || ''
|
||||
const fragmentErrorDescription =
|
||||
params.get('error_description') || params.get('error_message') || ''
|
||||
|
||||
if (fragmentError) {
|
||||
appStore.showError(fragmentErrorDescription || fragmentError)
|
||||
return
|
||||
}
|
||||
if (!tokenResponse) {
|
||||
if (route.path === '/auth/oauth/callback') {
|
||||
await resumePendingEmailOAuth()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
isProcessing.value = true
|
||||
try {
|
||||
await finalizeTokenResponse(tokenResponse, params.get('redirect') || '/dashboard')
|
||||
} catch (error: unknown) {
|
||||
const message = (error as { message?: string })?.message || t('auth.loginFailed')
|
||||
appStore.showError(message)
|
||||
isProcessing.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
error,
|
||||
|
||||
Reference in New Issue
Block a user