diff --git a/frontend/src/api/admin/gemini.ts b/frontend/src/api/admin/gemini.ts new file mode 100644 index 00000000..0a2c4a66 --- /dev/null +++ b/frontend/src/api/admin/gemini.ts @@ -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 + +export async function generateAuthUrl( + payload: GeminiAuthUrlRequest +): Promise { + const { data } = await apiClient.post( + '/admin/gemini/oauth/auth-url', + payload + ) + return data +} + +export async function exchangeCode(payload: GeminiExchangeCodeRequest): Promise { + const { data } = await apiClient.post( + '/admin/gemini/oauth/exchange-code', + payload + ) + return data +} + +export default { generateAuthUrl, exchangeCode } diff --git a/frontend/src/composables/useGeminiOAuth.ts b/frontend/src/composables/useGeminiOAuth.ts new file mode 100644 index 00000000..63e5c2b9 --- /dev/null +++ b/frontend/src/composables/useGeminiOAuth.ts @@ -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 => { + 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 = { 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 => { + 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 = { + 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 => { + 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 + } +} diff --git a/frontend/src/views/auth/OAuthCallbackView.vue b/frontend/src/views/auth/OAuthCallbackView.vue new file mode 100644 index 00000000..64489e42 --- /dev/null +++ b/frontend/src/views/auth/OAuthCallbackView.vue @@ -0,0 +1,87 @@ + + +