mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
feat(grok): 支持 Web SSO 批量导入并转换为 Build OAuth
新增 Grok Web SSO → xAI Device Flow → Grok Build OAuth 导入链路, 支持管理员批量粘贴 SSO key 创建 OAuth 账号。 - 后端:ConvertSSOToBuild、ConvertFromSSO、POST /admin/grok/sso-to-oauth - 批量:3 worker 并发,失败跳过并汇总 created/failed,worker panic recover - 无 refresh_token 时写入 expires_at 并强制 auto_pause_on_expired - 前端:SSO Cookie 导入入口、动态超时、中英文案、部分成功不关弹窗 - 测试:pkg/service/handler/前端超时单测;本地 Docker 真实 SSO e2e 通过
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { post } = vi.hoisted(() => ({
|
||||
post: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: { post },
|
||||
}))
|
||||
|
||||
import { createFromSSO, getGrokSSOImportTimeout } from '@/api/admin/grok'
|
||||
|
||||
describe('admin Grok SSO import API', () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset()
|
||||
post.mockResolvedValue({ data: { created: [], failed: [] } })
|
||||
})
|
||||
|
||||
it.each([
|
||||
[1, 180_000],
|
||||
[3, 180_000],
|
||||
[4, 270_000],
|
||||
[7, 360_000],
|
||||
])('uses a timeout sized for %i keys', async (keyCount, expectedTimeout) => {
|
||||
expect(getGrokSSOImportTimeout(keyCount)).toBe(expectedTimeout)
|
||||
|
||||
await createFromSSO({
|
||||
sso_tokens: Array.from({ length: keyCount }, (_, index) => `sso-${index + 1}`),
|
||||
})
|
||||
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/admin/grok/sso-to-oauth',
|
||||
expect.objectContaining({ sso_tokens: expect.any(Array) }),
|
||||
{ timeout: expectedTimeout },
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -34,11 +34,51 @@ export interface GrokTokenInfo {
|
||||
scope?: string
|
||||
client_id?: string
|
||||
email?: string
|
||||
sub?: string
|
||||
team_id?: string
|
||||
subscription_tier?: string
|
||||
entitlement_status?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface GrokSSOToOAuthRequest {
|
||||
sso_tokens: string[]
|
||||
name?: string
|
||||
notes?: string | null
|
||||
proxy_id?: number | null
|
||||
group_ids?: number[]
|
||||
credentials?: Record<string, unknown>
|
||||
extra?: Record<string, unknown>
|
||||
concurrency?: number
|
||||
load_factor?: number
|
||||
priority?: number
|
||||
rate_multiplier?: number
|
||||
expires_at?: number | null
|
||||
auto_pause_on_expired?: boolean
|
||||
}
|
||||
|
||||
export interface GrokSSOToOAuthItemResult {
|
||||
index: number
|
||||
name?: string
|
||||
email?: string
|
||||
account?: unknown
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface GrokSSOToOAuthResponse {
|
||||
created: GrokSSOToOAuthItemResult[]
|
||||
failed: GrokSSOToOAuthItemResult[]
|
||||
}
|
||||
|
||||
const GROK_SSO_IMPORT_CONCURRENCY = 3
|
||||
const GROK_SSO_IMPORT_TIMEOUT_PER_BATCH_MS = 90_000
|
||||
const GROK_SSO_IMPORT_TIMEOUT_BUFFER_MS = 90_000
|
||||
|
||||
export function getGrokSSOImportTimeout(keyCount: number): number {
|
||||
const batches = Math.ceil(Math.max(1, keyCount) / GROK_SSO_IMPORT_CONCURRENCY)
|
||||
return batches * GROK_SSO_IMPORT_TIMEOUT_PER_BATCH_MS + GROK_SSO_IMPORT_TIMEOUT_BUFFER_MS
|
||||
}
|
||||
|
||||
export interface GrokQuotaWindow {
|
||||
limit?: number | null
|
||||
remaining?: number | null
|
||||
@@ -119,4 +159,13 @@ export async function resetQuota(id: number): Promise<GrokQuotaResetResult> {
|
||||
return data
|
||||
}
|
||||
|
||||
export default { generateAuthUrl, exchangeCode, refreshGrokToken, queryQuota, resetQuota }
|
||||
export async function createFromSSO(payload: GrokSSOToOAuthRequest): Promise<GrokSSOToOAuthResponse> {
|
||||
const { data } = await apiClient.post<GrokSSOToOAuthResponse>(
|
||||
'/admin/grok/sso-to-oauth',
|
||||
payload,
|
||||
{ timeout: getGrokSSOImportTimeout(payload.sso_tokens.length) }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export default { generateAuthUrl, exchangeCode, refreshGrokToken, queryQuota, resetQuota, createFromSSO }
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
:required="!isGrokSSOImport"
|
||||
class="input"
|
||||
:placeholder="t('admin.accounts.enterAccountName')"
|
||||
data-tour="account-form-name"
|
||||
@@ -355,7 +355,7 @@
|
||||
<!-- Account Type Selection (Grok) -->
|
||||
<div v-if="form.platform === 'grok'">
|
||||
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
|
||||
<div class="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2" data-tour="account-form-type">
|
||||
<div class="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-3" data-tour="account-form-type">
|
||||
<button
|
||||
type="button"
|
||||
@click="accountCategory = 'oauth-based'"
|
||||
@@ -382,6 +382,36 @@
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="accountCategory = 'sso_cookie'"
|
||||
:class="[
|
||||
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
|
||||
accountCategory === 'sso_cookie'
|
||||
? 'border-teal-500 bg-teal-50 dark:bg-teal-900/20'
|
||||
: 'border-gray-200 hover:border-teal-300 dark:border-dark-600 dark:hover:border-teal-700'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||
accountCategory === 'sso_cookie'
|
||||
? 'bg-teal-500 text-white'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
|
||||
]"
|
||||
>
|
||||
<Icon name="link" size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.accounts.oauth.grok.ssoCookieAuth') }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.oauth.grok.ssoCookieHint') }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="grok-account-type-api-key"
|
||||
@@ -1966,7 +1996,7 @@
|
||||
|
||||
<!-- OpenAI OAuth Model Mapping (OAuth 类型没有 apikey 容器,需要独立的模型映射区域) -->
|
||||
<div
|
||||
v-if="(form.platform === 'openai' || form.platform === 'grok') && accountCategory === 'oauth-based'"
|
||||
v-if="(form.platform === 'openai' || form.platform === 'grok') && isOAuthFlow"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
|
||||
@@ -3080,12 +3110,15 @@
|
||||
:show-proxy-warning="form.platform !== 'openai' && form.platform !== 'grok' && !!form.proxy_id"
|
||||
:allow-multiple="form.platform === 'anthropic'"
|
||||
:show-cookie-option="form.platform === 'anthropic'"
|
||||
:show-refresh-token-option="form.platform === 'openai' || form.platform === 'antigravity' || form.platform === 'grok'"
|
||||
:show-refresh-token-option="form.platform === 'openai' || form.platform === 'antigravity' || (form.platform === 'grok' && !isGrokSSOImport)"
|
||||
:show-mobile-refresh-token-option="form.platform === 'openai'"
|
||||
:show-session-token-option="false"
|
||||
:show-access-token-option="false"
|
||||
:show-codex-session-import-option="form.platform === 'openai'"
|
||||
:show-codex-pat-option="form.platform === 'openai'"
|
||||
:show-sso-option="form.platform === 'grok'"
|
||||
:show-manual-option="!isGrokSSOImport"
|
||||
:initial-input-method="initialOAuthInputMethod"
|
||||
:platform="form.platform"
|
||||
:show-project-id="geminiOAuthType === 'code_assist'"
|
||||
@generate-url="handleGenerateUrl"
|
||||
@@ -3095,6 +3128,7 @@
|
||||
@validate-session-token="handleValidateSessionToken"
|
||||
@import-codex-session="handleOpenAIImportCodexSession"
|
||||
@import-codex-pat="handleOpenAIImportCodexPAT"
|
||||
@import-sso="handleGrokImportSSO"
|
||||
/>
|
||||
|
||||
</div>
|
||||
@@ -3492,6 +3526,7 @@ interface OAuthFlowExposed {
|
||||
sessionToken: string
|
||||
codexSession: string
|
||||
codexPAT: string
|
||||
ssoCookie: string
|
||||
inputMethod: AuthInputMethod
|
||||
reset: () => void
|
||||
}
|
||||
@@ -3595,7 +3630,7 @@ interface TempUnschedRuleForm {
|
||||
// State
|
||||
const step = ref(1)
|
||||
const submitting = ref(false)
|
||||
const accountCategory = ref<'oauth-based' | 'apikey' | 'bedrock' | 'service_account'>('oauth-based') // UI selection for account category
|
||||
const accountCategory = ref<'oauth-based' | 'apikey' | 'bedrock' | 'service_account' | 'sso_cookie'>('oauth-based') // UI selection for account category
|
||||
const addMethod = ref<AddMethod>('oauth') // For oauth-based: 'oauth' or 'setup-token'
|
||||
const apiKeyBaseUrl = ref('https://api.anthropic.com')
|
||||
const apiKeyValue = ref('')
|
||||
@@ -3973,9 +4008,13 @@ const isOAuthFlow = computed(() => {
|
||||
if (form.platform === 'anthropic' && accountCategory.value === 'bedrock') {
|
||||
return false
|
||||
}
|
||||
return accountCategory.value === 'oauth-based'
|
||||
return accountCategory.value === 'oauth-based' || (form.platform === 'grok' && accountCategory.value === 'sso_cookie')
|
||||
})
|
||||
|
||||
const isGrokSSOImport = computed(() => form.platform === 'grok' && accountCategory.value === 'sso_cookie')
|
||||
|
||||
const initialOAuthInputMethod = computed<AuthInputMethod>(() => isGrokSSOImport.value ? 'sso_cookie' : 'manual')
|
||||
|
||||
const isManualInputMethod = computed(() => {
|
||||
return oauthFlowRef.value?.inputMethod === 'manual'
|
||||
})
|
||||
@@ -4049,7 +4088,7 @@ watch(
|
||||
}
|
||||
if ((form.platform === 'gemini' || form.platform === 'anthropic') && category === 'service_account') {
|
||||
form.type = 'service_account' as AccountType
|
||||
} else if (category === 'oauth-based') {
|
||||
} else if (category === 'oauth-based' || (form.platform === 'grok' && category === 'sso_cookie')) {
|
||||
form.type = form.platform === 'anthropic' ? method as AccountType : 'oauth'
|
||||
} else {
|
||||
form.type = 'apikey'
|
||||
@@ -4103,6 +4142,9 @@ watch(
|
||||
if (newPlatform !== 'anthropic' && accountCategory.value === 'bedrock') {
|
||||
accountCategory.value = 'oauth-based'
|
||||
}
|
||||
if (newPlatform !== 'grok' && accountCategory.value === 'sso_cookie') {
|
||||
accountCategory.value = 'oauth-based'
|
||||
}
|
||||
// Reset Bedrock fields when switching platforms
|
||||
bedrockAccessKeyId.value = ''
|
||||
bedrockSecretAccessKey.value = ''
|
||||
@@ -4766,7 +4808,7 @@ const handleVertexServiceAccountDrop = async (event: DragEvent) => {
|
||||
const handleSubmit = async () => {
|
||||
// For OAuth-based type, handle OAuth flow (goes to step 2)
|
||||
if (isOAuthFlow.value) {
|
||||
if (!form.name.trim()) {
|
||||
if (!isGrokSSOImport.value && !form.name.trim()) {
|
||||
appStore.showError(t('admin.accounts.pleaseEnterAccountName'))
|
||||
return
|
||||
}
|
||||
@@ -5204,6 +5246,76 @@ const handleGrokValidateRT = async (refreshTokenInput: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleGrokImportSSO = async (ssoInput: string) => {
|
||||
// Align with OpenAI/Grok RT batch import: one token per line, no client-side dedupe.
|
||||
const ssoTokens = ssoInput
|
||||
.split('\n')
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token)
|
||||
if (ssoTokens.length === 0) return
|
||||
|
||||
grokOAuth.loading.value = true
|
||||
grokOAuth.error.value = ''
|
||||
|
||||
const credentials: Record<string, unknown> = {}
|
||||
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
|
||||
if (modelMapping) {
|
||||
credentials.model_mapping = modelMapping
|
||||
}
|
||||
if (!applyTempUnschedConfig(credentials)) {
|
||||
grokOAuth.loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await adminAPI.grok.createFromSSO({
|
||||
sso_tokens: ssoTokens,
|
||||
name: form.name || undefined,
|
||||
notes: form.notes || undefined,
|
||||
proxy_id: form.proxy_id,
|
||||
group_ids: form.group_ids,
|
||||
credentials,
|
||||
concurrency: form.concurrency,
|
||||
load_factor: form.load_factor ?? undefined,
|
||||
priority: form.priority,
|
||||
rate_multiplier: form.rate_multiplier,
|
||||
expires_at: form.expires_at,
|
||||
auto_pause_on_expired: autoPauseOnExpired.value
|
||||
})
|
||||
|
||||
const successCount = result.created?.length || 0
|
||||
const failedCount = result.failed?.length || 0
|
||||
if (successCount > 0 && failedCount === 0) {
|
||||
appStore.showSuccess(
|
||||
ssoTokens.length > 1
|
||||
? t('admin.accounts.oauth.batchSuccess', { count: successCount })
|
||||
: t('admin.accounts.accountCreated')
|
||||
)
|
||||
emit('created')
|
||||
handleClose()
|
||||
} else if (successCount > 0 && failedCount > 0) {
|
||||
// Same as OpenAI/Grok RT: keep input, show failures, refresh list.
|
||||
appStore.showWarning(
|
||||
t('admin.accounts.oauth.batchPartialSuccess', { success: successCount, failed: failedCount })
|
||||
)
|
||||
grokOAuth.error.value = (result.failed || [])
|
||||
.map((item) => `#${item.index}: ${item.error || 'Unknown error'}`)
|
||||
.join('\n')
|
||||
emit('created')
|
||||
} else {
|
||||
grokOAuth.error.value = (result.failed || [])
|
||||
.map((item) => `#${item.index}: ${item.error || 'Unknown error'}`)
|
||||
.join('\n') || t('admin.accounts.oauth.grok.failedToConvertSSO')
|
||||
appStore.showError(t('admin.accounts.oauth.batchFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
grokOAuth.error.value = error.response?.data?.detail || error.message || t('admin.accounts.oauth.grok.failedToConvertSSO')
|
||||
appStore.showError(grokOAuth.error.value)
|
||||
} finally {
|
||||
grokOAuth.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI OAuth 授权码兑换
|
||||
const handleOpenAIExchange = async (authCode: string) => {
|
||||
const oauthClient = openaiOAuth
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
{{ methodLabel }}
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<label v-if="showManualOption" class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
v-model="inputMethod"
|
||||
type="radio"
|
||||
@@ -48,6 +48,17 @@
|
||||
t(getOAuthKey('refreshTokenAuth'))
|
||||
}}</span>
|
||||
</label>
|
||||
<label v-if="showSsoOption" class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
v-model="inputMethod"
|
||||
type="radio"
|
||||
value="sso_cookie"
|
||||
class="text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="text-sm text-blue-900 dark:text-blue-200">{{
|
||||
t(getOAuthKey('ssoCookieAuth'))
|
||||
}}</span>
|
||||
</label>
|
||||
<label v-if="showMobileRefreshTokenOption" class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
v-model="inputMethod"
|
||||
@@ -190,6 +201,81 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSO Cookie Input (Grok Web -> Grok Build) -->
|
||||
<div v-if="inputMethod === 'sso_cookie'" class="space-y-4">
|
||||
<div
|
||||
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
|
||||
>
|
||||
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
|
||||
{{ t(getOAuthKey('ssoCookieDesc')) }}
|
||||
</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<label
|
||||
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<Icon name="key" size="sm" class="text-blue-500" />
|
||||
{{ t(getOAuthKey('ssoCookieLabel')) }}
|
||||
<span
|
||||
v-if="parsedSSOCount > 1"
|
||||
class="rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white"
|
||||
>
|
||||
{{ t('admin.accounts.oauth.keysCount', { count: parsedSSOCount }) }}
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="ssoCookieInput"
|
||||
rows="5"
|
||||
class="input w-full resize-y font-mono text-sm"
|
||||
:placeholder="t(getOAuthKey('ssoCookiePlaceholder'))"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-blue-600 dark:text-blue-400">
|
||||
{{ t(getOAuthKey('ssoCookieHint')) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="error"
|
||||
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
|
||||
>
|
||||
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="loading || !ssoCookieInput.trim()"
|
||||
@click="handleImportSSO"
|
||||
>
|
||||
<svg
|
||||
v-if="loading"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<Icon v-else name="sparkles" size="sm" class="mr-2" />
|
||||
{{ loading ? t(getOAuthKey('convertingSSO')) : t(getOAuthKey('convertSSOAndCreate')) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Codex OAuth/session JSON batch import -->
|
||||
<div v-if="inputMethod === 'codex_session'" class="space-y-4">
|
||||
<div
|
||||
@@ -737,6 +823,9 @@ interface Props {
|
||||
showAccessTokenOption?: boolean
|
||||
showCodexSessionImportOption?: boolean
|
||||
showCodexPatOption?: boolean
|
||||
showSsoOption?: boolean
|
||||
showManualOption?: boolean
|
||||
initialInputMethod?: AuthInputMethod
|
||||
platform?: AccountPlatform // Platform type for different UI/text
|
||||
showProjectId?: boolean // New prop to control project ID visibility
|
||||
}
|
||||
@@ -757,6 +846,9 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
showAccessTokenOption: false,
|
||||
showCodexSessionImportOption: false,
|
||||
showCodexPatOption: false,
|
||||
showSsoOption: false,
|
||||
showManualOption: true,
|
||||
initialInputMethod: 'manual',
|
||||
platform: 'anthropic',
|
||||
showProjectId: true
|
||||
})
|
||||
@@ -771,6 +863,7 @@ const emit = defineEmits<{
|
||||
'import-access-token': [accessToken: string]
|
||||
'import-codex-session': [content: string]
|
||||
'import-codex-pat': [accessToken: string]
|
||||
'import-sso': [content: string]
|
||||
'update:inputMethod': [method: AuthInputMethod]
|
||||
}>()
|
||||
|
||||
@@ -807,19 +900,31 @@ const oauthImportantNotice = computed(() => {
|
||||
})
|
||||
|
||||
// Local state
|
||||
const inputMethod = ref<AuthInputMethod>(props.showCookieOption ? 'manual' : 'manual')
|
||||
const inputMethod = ref<AuthInputMethod>(props.initialInputMethod)
|
||||
const authCodeInput = ref('')
|
||||
const sessionKeyInput = ref('')
|
||||
const refreshTokenInput = ref('')
|
||||
const sessionTokenInput = ref('')
|
||||
const codexSessionInput = ref('')
|
||||
const codexPATInput = ref('')
|
||||
const ssoCookieInput = 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 || props.showMobileRefreshTokenOption || props.showSessionTokenOption || props.showAccessTokenOption || props.showCodexSessionImportOption || props.showCodexPatOption)
|
||||
// Computed: show method selection only when there is something to choose.
|
||||
const methodOptionCount = computed(() => [
|
||||
props.showManualOption,
|
||||
props.showCookieOption,
|
||||
props.showRefreshTokenOption,
|
||||
props.showMobileRefreshTokenOption,
|
||||
props.showSessionTokenOption,
|
||||
props.showAccessTokenOption,
|
||||
props.showCodexSessionImportOption,
|
||||
props.showCodexPatOption,
|
||||
props.showSsoOption
|
||||
].filter(Boolean).length)
|
||||
const showMethodSelection = computed(() => methodOptionCount.value > 1)
|
||||
|
||||
// Clipboard
|
||||
const { copied, copyToClipboard } = useClipboard()
|
||||
@@ -850,7 +955,18 @@ const parsedCodexSessionCount = computed(() => {
|
||||
.filter((item) => item).length
|
||||
})
|
||||
|
||||
const parsedSSOCount = computed(() => {
|
||||
return ssoCookieInput.value
|
||||
.split('\n')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item).length
|
||||
})
|
||||
|
||||
// Watchers
|
||||
watch(() => props.initialInputMethod, (newVal) => {
|
||||
inputMethod.value = newVal
|
||||
})
|
||||
|
||||
watch(inputMethod, (newVal) => {
|
||||
emit('update:inputMethod', newVal)
|
||||
})
|
||||
@@ -933,6 +1049,12 @@ const handleImportCodexPAT = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportSSO = () => {
|
||||
if (ssoCookieInput.value.trim()) {
|
||||
emit('import-sso', ssoCookieInput.value.trim())
|
||||
}
|
||||
}
|
||||
|
||||
// Expose methods and state
|
||||
defineExpose({
|
||||
authCode: authCodeInput,
|
||||
@@ -943,6 +1065,7 @@ defineExpose({
|
||||
sessionToken: sessionTokenInput,
|
||||
codexSession: codexSessionInput,
|
||||
codexPAT: codexPATInput,
|
||||
ssoCookie: ssoCookieInput,
|
||||
inputMethod,
|
||||
reset: () => {
|
||||
authCodeInput.value = ''
|
||||
@@ -953,7 +1076,8 @@ defineExpose({
|
||||
sessionTokenInput.value = ''
|
||||
codexSessionInput.value = ''
|
||||
codexPATInput.value = ''
|
||||
inputMethod.value = 'manual'
|
||||
ssoCookieInput.value = ''
|
||||
inputMethod.value = props.initialInputMethod
|
||||
showHelpDialog.value = false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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' | 'refresh_token' | 'mobile_refresh_token' | 'session_token' | 'access_token' | 'codex_session' | 'codex_pat'
|
||||
export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token' | 'mobile_refresh_token' | 'session_token' | 'access_token' | 'codex_session' | 'codex_pat' | 'sso_cookie'
|
||||
|
||||
export interface OAuthState {
|
||||
authUrl: string
|
||||
|
||||
@@ -121,6 +121,8 @@ export function useGrokOAuth() {
|
||||
client_id: tokenInfo.client_id,
|
||||
scope: tokenInfo.scope,
|
||||
email: tokenInfo.email,
|
||||
sub: tokenInfo.sub,
|
||||
team_id: tokenInfo.team_id,
|
||||
subscription_tier: tokenInfo.subscription_tier,
|
||||
entitlement_status: tokenInfo.entitlement_status,
|
||||
base_url: 'https://cli-chat-proxy.grok.com/v1'
|
||||
|
||||
@@ -870,6 +870,13 @@ export default {
|
||||
refreshTokenAuth: 'Manual RT Input',
|
||||
refreshTokenDesc: 'Enter existing xAI refresh token(s). Supports batch input, one per line.',
|
||||
refreshTokenPlaceholder: 'Paste your xAI refresh token...\nSupports multiple, one per line',
|
||||
ssoCookieAuth: 'SSO Cookie Import',
|
||||
ssoCookieDesc: 'Paste one Grok Web SSO key per line. The server will complete the xAI Device Flow and convert them into Grok Build OAuth credentials.',
|
||||
ssoCookieLabel: 'Grok Web SSO Key',
|
||||
ssoCookiePlaceholder: 'One SSO key per line\nSupports multiple, one per line',
|
||||
ssoCookieHint: 'One SSO key per line. Multiple keys are imported with 3-way concurrency; expect about 90 seconds per batch. Use a matching-region proxy if needed.',
|
||||
convertingSSO: 'Converting...',
|
||||
convertSSOAndCreate: 'Convert & Create Account',
|
||||
validating: 'Validating...',
|
||||
validateAndCreate: 'Validate & Create Account',
|
||||
pleaseEnterRefreshToken: 'Please enter Refresh Token',
|
||||
@@ -877,6 +884,7 @@ export default {
|
||||
missingExchangeParams: 'Missing authorization code, state, or OAuth session',
|
||||
failedToExchangeCode: 'Failed to exchange Grok authorization code',
|
||||
failedToValidateRT: 'Failed to validate Grok refresh token',
|
||||
failedToConvertSSO: 'Failed to convert Grok SSO cookie',
|
||||
errors: {
|
||||
GROK_OAUTH_SESSION_NOT_FOUND:
|
||||
'Grok OAuth session was not found or has expired. Generate a new auth URL and paste the newest callback URL.',
|
||||
|
||||
@@ -956,6 +956,13 @@ export default {
|
||||
refreshTokenAuth: '手动输入 RT',
|
||||
refreshTokenDesc: '输入已有的 xAI refresh token,支持批量输入(每行一个)。',
|
||||
refreshTokenPlaceholder: '粘贴您的 xAI refresh token...\n支持多个,每行一个',
|
||||
ssoCookieAuth: 'SSO Cookie 导入',
|
||||
ssoCookieDesc: '每行粘贴一个 Grok Web SSO key,系统会自动走 xAI Device Flow 并转换为 Grok Build OAuth 凭据。',
|
||||
ssoCookieLabel: 'Grok Web SSO Key',
|
||||
ssoCookiePlaceholder: '每行一个 SSO key\n支持多个,每行一个',
|
||||
ssoCookieHint: '每行一个 SSO key;多个 key 会 3 路并发导入,耗时约 90 秒 × 批次数,建议使用对应地区代理。',
|
||||
convertingSSO: '转换中...',
|
||||
convertSSOAndCreate: '转换并创建账号',
|
||||
validating: '验证中...',
|
||||
validateAndCreate: '验证并创建账号',
|
||||
pleaseEnterRefreshToken: '请输入 Refresh Token',
|
||||
@@ -963,6 +970,7 @@ export default {
|
||||
missingExchangeParams: '缺少授权码、state 或 OAuth 会话',
|
||||
failedToExchangeCode: 'Grok 授权码兑换失败',
|
||||
failedToValidateRT: '验证 Grok refresh token 失败',
|
||||
failedToConvertSSO: 'Grok SSO 转换失败',
|
||||
errors: {
|
||||
GROK_OAUTH_SESSION_NOT_FOUND:
|
||||
'Grok OAuth 会话不存在或已过期。请重新生成授权链接,并粘贴最新的回调链接。',
|
||||
|
||||
Reference in New Issue
Block a user