diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 6df93498..0dec4da5 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -398,6 +398,26 @@ export async function getAntigravityDefaultModelMapping(): Promise> { + const payload: { refresh_token: string; proxy_id?: number } = { + refresh_token: refreshToken + } + if (proxyId) { + payload.proxy_id = proxyId + } + const { data } = await apiClient.post>('/admin/openai/refresh-token', payload) + return data +} + export const accountsAPI = { list, getById, @@ -418,6 +438,7 @@ export const accountsAPI = { getAvailableModels, generateAuthUrl, exchangeCode, + refreshOpenAIToken, batchCreate, batchUpdateCredentials, bulkUpdate, diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 603941c1..18bac7ff 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -1650,10 +1650,12 @@ :show-proxy-warning="form.platform !== 'openai' && !!form.proxy_id" :allow-multiple="form.platform === 'anthropic'" :show-cookie-option="form.platform === 'anthropic'" + :show-refresh-token-option="form.platform === 'openai'" :platform="form.platform" :show-project-id="geminiOAuthType === 'code_assist'" @generate-url="handleGenerateUrl" @cookie-auth="handleCookieAuth" + @validate-refresh-token="handleOpenAIValidateRT" /> @@ -2010,6 +2012,7 @@ interface OAuthFlowExposed { oauthState: string projectId: string sessionKey: string + refreshToken: string inputMethod: AuthInputMethod reset: () => void } @@ -2861,6 +2864,95 @@ const handleOpenAIExchange = async (authCode: string) => { } } +// OpenAI 手动 RT 批量验证和创建 +const handleOpenAIValidateRT = async (refreshTokenInput: string) => { + if (!refreshTokenInput.trim()) return + + // Parse multiple refresh tokens (one per line) + const refreshTokens = refreshTokenInput + .split('\n') + .map((rt) => rt.trim()) + .filter((rt) => rt) + + if (refreshTokens.length === 0) { + openaiOAuth.error.value = t('admin.accounts.oauth.openai.pleaseEnterRefreshToken') + return + } + + openaiOAuth.loading.value = true + openaiOAuth.error.value = '' + + let successCount = 0 + let failedCount = 0 + const errors: string[] = [] + + try { + for (let i = 0; i < refreshTokens.length; i++) { + try { + const tokenInfo = await openaiOAuth.validateRefreshToken( + refreshTokens[i], + form.proxy_id + ) + if (!tokenInfo) { + failedCount++ + errors.push(`#${i + 1}: ${openaiOAuth.error.value || 'Validation failed'}`) + openaiOAuth.error.value = '' + continue + } + + const credentials = openaiOAuth.buildCredentials(tokenInfo) + const extra = openaiOAuth.buildExtraInfo(tokenInfo) + + // Generate account name with index for batch + const accountName = refreshTokens.length > 1 ? `${form.name} #${i + 1}` : form.name + + await adminAPI.accounts.create({ + name: accountName, + notes: form.notes, + platform: 'openai', + type: 'oauth', + credentials, + extra, + proxy_id: form.proxy_id, + concurrency: form.concurrency, + priority: form.priority, + rate_multiplier: form.rate_multiplier, + group_ids: form.group_ids, + expires_at: form.expires_at, + auto_pause_on_expired: autoPauseOnExpired.value + }) + successCount++ + } catch (error: any) { + failedCount++ + const errMsg = error.response?.data?.detail || error.message || 'Unknown error' + errors.push(`#${i + 1}: ${errMsg}`) + } + } + + // Show results + if (successCount > 0 && failedCount === 0) { + appStore.showSuccess( + refreshTokens.length > 1 + ? t('admin.accounts.oauth.batchSuccess', { count: successCount }) + : t('admin.accounts.accountCreated') + ) + emit('created') + handleClose() + } else if (successCount > 0 && failedCount > 0) { + appStore.showWarning( + t('admin.accounts.oauth.batchPartialSuccess', { success: successCount, failed: failedCount }) + ) + openaiOAuth.error.value = errors.join('\n') + emit('created') + } else { + openaiOAuth.error.value = errors.join('\n') + appStore.showError(t('admin.accounts.oauth.batchFailed')) + } + } finally { + openaiOAuth.loading.value = false + } +} + // Gemini OAuth 授权码兑换 const handleGeminiExchange = async (authCode: string) => { if (!authCode.trim() || !geminiOAuth.sessionId.value) return diff --git a/frontend/src/components/account/OAuthAuthorizationFlow.vue b/frontend/src/components/account/OAuthAuthorizationFlow.vue index 194237fa..78f488c1 100644 --- a/frontend/src/components/account/OAuthAuthorizationFlow.vue +++ b/frontend/src/components/account/OAuthAuthorizationFlow.vue @@ -10,11 +10,11 @@

{{ oauthTitle }}

-
+
-
+
-
+
+ + +
+
+

+ {{ t('admin.accounts.oauth.openai.refreshTokenDesc') }} +

+ + +
+ + +

+ {{ t('admin.accounts.oauth.batchCreateAccounts', { count: parsedRefreshTokenCount }) }} +

+
+ + +
+

+ {{ error }} +

+
+ + +
@@ -173,7 +268,7 @@
-
+

{{ oauthFollowSteps }}

@@ -428,6 +523,7 @@ interface Props { allowMultiple?: boolean methodLabel?: string showCookieOption?: boolean // Whether to show cookie auto-auth option + showRefreshTokenOption?: boolean // Whether to show refresh token input option (OpenAI only) platform?: 'anthropic' | 'openai' | 'gemini' | 'antigravity' // Platform type for different UI/text showProjectId?: boolean // New prop to control project ID visibility } @@ -442,6 +538,7 @@ const props = withDefaults(defineProps(), { allowMultiple: false, methodLabel: 'Authorization Method', showCookieOption: true, + showRefreshTokenOption: false, platform: 'anthropic', showProjectId: true }) @@ -450,6 +547,7 @@ const emit = defineEmits<{ 'generate-url': [] 'exchange-code': [code: string] 'cookie-auth': [sessionKey: string] + 'validate-refresh-token': [refreshToken: string] 'update:inputMethod': [method: AuthInputMethod] }>() @@ -487,10 +585,14 @@ const oauthImportantNotice = computed(() => { const inputMethod = ref(props.showCookieOption ? 'manual' : 'manual') const authCodeInput = ref('') const sessionKeyInput = ref('') +const refreshTokenInput = ref('') const showHelpDialog = ref(false) const oauthState = ref('') const projectId = ref('') +// Computed: show method selection when either cookie or refresh token option is enabled +const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption) + // Clipboard const { copied, copyToClipboard } = useClipboard() @@ -502,6 +604,14 @@ const parsedKeyCount = computed(() => { .filter((k) => k).length }) +// Computed: count of refresh tokens entered +const parsedRefreshTokenCount = computed(() => { + return refreshTokenInput.value + .split('\n') + .map((rt) => rt.trim()) + .filter((rt) => rt).length +}) + // Watchers watch(inputMethod, (newVal) => { emit('update:inputMethod', newVal) @@ -563,18 +673,26 @@ const handleCookieAuth = () => { } } +const handleValidateRefreshToken = () => { + if (refreshTokenInput.value.trim()) { + emit('validate-refresh-token', refreshTokenInput.value.trim()) + } +} + // Expose methods and state defineExpose({ authCode: authCodeInput, oauthState, projectId, sessionKey: sessionKeyInput, + refreshToken: refreshTokenInput, inputMethod, reset: () => { authCodeInput.value = '' oauthState.value = '' projectId.value = '' sessionKeyInput.value = '' + refreshTokenInput.value = '' inputMethod.value = 'manual' showHelpDialog.value = false } diff --git a/frontend/src/composables/useAccountOAuth.ts b/frontend/src/composables/useAccountOAuth.ts index bdc6f0f1..ca200cb3 100644 --- a/frontend/src/composables/useAccountOAuth.ts +++ b/frontend/src/composables/useAccountOAuth.ts @@ -3,7 +3,7 @@ import { useAppStore } from '@/stores/app' import { adminAPI } from '@/api/admin' export type AddMethod = 'oauth' | 'setup-token' -export type AuthInputMethod = 'manual' | 'cookie' +export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token' export interface OAuthState { authUrl: string diff --git a/frontend/src/composables/useOpenAIOAuth.ts b/frontend/src/composables/useOpenAIOAuth.ts index 4b5ffe31..82a77031 100644 --- a/frontend/src/composables/useOpenAIOAuth.ts +++ b/frontend/src/composables/useOpenAIOAuth.ts @@ -105,6 +105,32 @@ export function useOpenAIOAuth() { } } + // Validate refresh token and get full token info + const validateRefreshToken = async ( + refreshToken: string, + proxyId?: number | null + ): Promise => { + if (!refreshToken.trim()) { + error.value = 'Missing refresh token' + return null + } + + loading.value = true + error.value = '' + + try { + // Use dedicated refresh-token endpoint + const tokenInfo = await adminAPI.accounts.refreshOpenAIToken(refreshToken.trim(), proxyId) + return tokenInfo as OpenAITokenInfo + } catch (err: any) { + error.value = err.response?.data?.detail || 'Failed to validate refresh token' + appStore.showError(error.value) + return null + } finally { + loading.value = false + } + } + // Build credentials for OpenAI OAuth account const buildCredentials = (tokenInfo: OpenAITokenInfo): Record => { const creds: Record = { @@ -152,6 +178,7 @@ export function useOpenAIOAuth() { resetState, generateAuthUrl, exchangeAuthCode, + validateRefreshToken, buildCredentials, buildExtraInfo } diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 1c71006d..1cc1fc18 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1662,6 +1662,9 @@ export default { cookieAuthFailed: 'Cookie authorization failed', keyAuthFailed: 'Key {index}: {error}', successCreated: 'Successfully created {count} account(s)', + batchSuccess: 'Successfully created {count} account(s)', + batchPartialSuccess: 'Partial success: {success} succeeded, {failed} failed', + batchFailed: 'Batch creation failed', // OpenAI specific openai: { title: 'OpenAI Account Authorization', @@ -1680,7 +1683,14 @@ export default { authCodePlaceholder: 'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value', authCodeHint: - 'You can copy the entire URL or just the code parameter value, the system will auto-detect' + 'You can copy the entire URL or just the code parameter value, the system will auto-detect', + // Refresh Token auth + refreshTokenAuth: 'Manual RT Input', + refreshTokenDesc: 'Enter your existing OpenAI Refresh Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.', + refreshTokenPlaceholder: 'Paste your OpenAI Refresh Token...\nSupports multiple, one per line', + validating: 'Validating...', + validateAndCreate: 'Validate & Create Account', + pleaseEnterRefreshToken: 'Please enter Refresh Token' }, // Gemini specific gemini: { diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 5eef6243..c739d8e7 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1804,6 +1804,9 @@ export default { cookieAuthFailed: 'Cookie 授权失败', keyAuthFailed: '密钥 {index}: {error}', successCreated: '成功创建 {count} 个账号', + batchSuccess: '成功创建 {count} 个账号', + batchPartialSuccess: '部分成功:{success} 个成功,{failed} 个失败', + batchFailed: '批量创建失败', // OpenAI specific openai: { title: 'OpenAI 账户授权', @@ -1820,7 +1823,14 @@ export default { authCode: '授权链接或 Code', authCodePlaceholder: '方式1:复制完整的链接\n(http://localhost:xxx/auth/callback?code=...)\n方式2:仅复制 code 参数的值', - authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别' + authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别', + // Refresh Token auth + refreshTokenAuth: '手动输入 RT', + refreshTokenDesc: '输入您已有的 OpenAI Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。', + refreshTokenPlaceholder: '粘贴您的 OpenAI Refresh Token...\n支持多个,每行一个', + validating: '验证中...', + validateAndCreate: '验证并创建账号', + pleaseEnterRefreshToken: '请输入 Refresh Token' }, // Gemini specific gemini: {