feat(frontend): 新增 Gemini OAuth 授权流程
- 新增 /admin/gemini API 接口封装(generateAuthUrl, exchangeCode) - 新增 useGeminiOAuth composable 处理 Gemini OAuth 流程 - 新增 OAuthCallbackView 视图用于接收 OAuth 回调 - 支持 code/state 参数提取和 credentials 构建
This commit is contained in:
47
frontend/src/api/admin/gemini.ts
Normal file
47
frontend/src/api/admin/gemini.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Admin Gemini API endpoints
|
||||
* Handles Gemini OAuth flows for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
|
||||
export interface GeminiAuthUrlResponse {
|
||||
auth_url: string
|
||||
session_id: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export interface GeminiAuthUrlRequest {
|
||||
redirect_uri: string
|
||||
proxy_id?: number
|
||||
}
|
||||
|
||||
export interface GeminiExchangeCodeRequest {
|
||||
session_id: string
|
||||
state: string
|
||||
code: string
|
||||
redirect_uri: string
|
||||
proxy_id?: number
|
||||
}
|
||||
|
||||
export type GeminiTokenInfo = Record<string, unknown>
|
||||
|
||||
export async function generateAuthUrl(
|
||||
payload: GeminiAuthUrlRequest
|
||||
): Promise<GeminiAuthUrlResponse> {
|
||||
const { data } = await apiClient.post<GeminiAuthUrlResponse>(
|
||||
'/admin/gemini/oauth/auth-url',
|
||||
payload
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function exchangeCode(payload: GeminiExchangeCodeRequest): Promise<GeminiTokenInfo> {
|
||||
const { data } = await apiClient.post<GeminiTokenInfo>(
|
||||
'/admin/gemini/oauth/exchange-code',
|
||||
payload
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export default { generateAuthUrl, exchangeCode }
|
||||
133
frontend/src/composables/useGeminiOAuth.ts
Normal file
133
frontend/src/composables/useGeminiOAuth.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
|
||||
export interface GeminiTokenInfo {
|
||||
access_token?: string
|
||||
refresh_token?: string
|
||||
token_type?: string
|
||||
scope?: string
|
||||
expires_at?: number | string
|
||||
project_id?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export function useGeminiOAuth() {
|
||||
const appStore = useAppStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const authUrl = ref('')
|
||||
const sessionId = ref('')
|
||||
const state = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const resetState = () => {
|
||||
authUrl.value = ''
|
||||
sessionId.value = ''
|
||||
state.value = ''
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
const generateAuthUrl = async (
|
||||
proxyId: number | null | undefined,
|
||||
redirectUri: string
|
||||
): Promise<boolean> => {
|
||||
loading.value = true
|
||||
authUrl.value = ''
|
||||
sessionId.value = ''
|
||||
state.value = ''
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
if (!redirectUri?.trim()) {
|
||||
error.value = t('admin.accounts.oauth.gemini.missingRedirectUri')
|
||||
appStore.showError(error.value)
|
||||
return false
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = { redirect_uri: redirectUri.trim() }
|
||||
if (proxyId) payload.proxy_id = proxyId
|
||||
|
||||
const response = await adminAPI.gemini.generateAuthUrl(payload as any)
|
||||
authUrl.value = response.auth_url
|
||||
sessionId.value = response.session_id
|
||||
state.value = response.state
|
||||
return true
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || t('admin.accounts.oauth.gemini.failedToGenerateUrl')
|
||||
appStore.showError(error.value)
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const exchangeAuthCode = async (params: {
|
||||
code: string
|
||||
sessionId: string
|
||||
state: string
|
||||
redirectUri: string
|
||||
proxyId?: number | null
|
||||
}): Promise<GeminiTokenInfo | null> => {
|
||||
const code = params.code?.trim()
|
||||
if (!code || !params.sessionId || !params.state || !params.redirectUri?.trim()) {
|
||||
error.value = t('admin.accounts.oauth.gemini.missingExchangeParams')
|
||||
return null
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
session_id: params.sessionId,
|
||||
state: params.state,
|
||||
code,
|
||||
redirect_uri: params.redirectUri.trim()
|
||||
}
|
||||
if (params.proxyId) payload.proxy_id = params.proxyId
|
||||
|
||||
const tokenInfo = await adminAPI.gemini.exchangeCode(payload as any)
|
||||
return tokenInfo as GeminiTokenInfo
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || t('admin.accounts.oauth.gemini.failedToExchangeCode')
|
||||
appStore.showError(error.value)
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const buildCredentials = (tokenInfo: GeminiTokenInfo): Record<string, unknown> => {
|
||||
let expiresAt: string | undefined
|
||||
if (typeof tokenInfo.expires_at === 'number' && Number.isFinite(tokenInfo.expires_at)) {
|
||||
expiresAt = Math.floor(tokenInfo.expires_at).toString()
|
||||
} else if (typeof tokenInfo.expires_at === 'string' && tokenInfo.expires_at.trim()) {
|
||||
expiresAt = tokenInfo.expires_at.trim()
|
||||
}
|
||||
|
||||
return {
|
||||
access_token: tokenInfo.access_token,
|
||||
refresh_token: tokenInfo.refresh_token,
|
||||
token_type: tokenInfo.token_type,
|
||||
expires_at: expiresAt,
|
||||
scope: tokenInfo.scope,
|
||||
project_id: tokenInfo.project_id
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authUrl,
|
||||
sessionId,
|
||||
state,
|
||||
loading,
|
||||
error,
|
||||
resetState,
|
||||
generateAuthUrl,
|
||||
exchangeAuthCode,
|
||||
buildCredentials
|
||||
}
|
||||
}
|
||||
87
frontend/src/views/auth/OAuthCallbackView.vue
Normal file
87
frontend/src/views/auth/OAuthCallbackView.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<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">
|
||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">OAuth Callback</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Copy the <code>code</code> (and <code>state</code> if needed) back to the admin
|
||||
authorization flow.
|
||||
</p>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<div>
|
||||
<label class="input-label">Code</label>
|
||||
<div class="flex gap-2">
|
||||
<input class="input flex-1 font-mono text-sm" :value="code" readonly />
|
||||
<button class="btn btn-secondary" type="button" :disabled="!code" @click="copy(code)">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">State</label>
|
||||
<div class="flex gap-2">
|
||||
<input class="input flex-1 font-mono text-sm" :value="state" readonly />
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
:disabled="!state"
|
||||
@click="copy(state)"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">Full URL</label>
|
||||
<div class="flex gap-2">
|
||||
<input class="input flex-1 font-mono text-xs" :value="fullUrl" readonly />
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
:disabled="!fullUrl"
|
||||
@click="copy(fullUrl)"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="error"
|
||||
class="rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
|
||||
>
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
|
||||
const route = useRoute()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
const code = computed(() => (route.query.code as string) || '')
|
||||
const state = computed(() => (route.query.state as string) || '')
|
||||
const error = computed(
|
||||
() => (route.query.error as string) || (route.query.error_description as string) || ''
|
||||
)
|
||||
|
||||
const fullUrl = computed(() => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
return window.location.href
|
||||
})
|
||||
|
||||
const copy = (value: string) => {
|
||||
if (!value) return
|
||||
copyToClipboard(value, 'Copied')
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user