feat(frontend): 新增 Gemini OAuth 授权流程

- 新增 /admin/gemini API 接口封装(generateAuthUrl, exchangeCode)
- 新增 useGeminiOAuth composable 处理 Gemini OAuth 流程
- 新增 OAuthCallbackView 视图用于接收 OAuth 回调
- 支持 code/state 参数提取和 credentials 构建
This commit is contained in:
ianshaw
2025-12-25 08:39:48 -08:00
parent 03a8ae62e5
commit 0b30cc2b7e
3 changed files with 267 additions and 0 deletions

View 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 }

View 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
}
}

View 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>