mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 22:20:19 +08:00
feat(providers): expand OAuth account management
Add Claude Code manual and cookie authorization, including redacted batch tasks. Harden OAuth imports, duplicate replacement, provider dialogs, and related account-management tests.
This commit is contained in:
@@ -13,12 +13,17 @@ vi.mock('@/api/client', () => ({
|
||||
}))
|
||||
|
||||
import {
|
||||
authorizeProviderWithCookie,
|
||||
getProviderCookieAuthorizeTaskStatus,
|
||||
getBatchImportOAuthTaskStatus,
|
||||
importProviderRefreshToken,
|
||||
startProviderCookieAuthorizeTask,
|
||||
startBatchImportOAuthTask,
|
||||
} from '@/api/endpoints/provider_oauth'
|
||||
|
||||
describe('Agent Identity OAuth management routes', () => {
|
||||
const CLAUDE_COOKIE_AUTHORIZE_TIMEOUT_MS = 4 * 60 * 1000
|
||||
|
||||
describe('Provider OAuth management routes', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
postMock.mockReset()
|
||||
@@ -137,4 +142,37 @@ describe('Agent Identity OAuth management routes', () => {
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('allows the sequential Claude Cookie exchange to outlive the global timeout', async () => {
|
||||
const payload = {
|
||||
cookie: 'sessionKey=claude-session-key',
|
||||
proxy_node_id: 'proxy-1',
|
||||
}
|
||||
|
||||
await authorizeProviderWithCookie('provider-claude', payload)
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
'/api/admin/provider-oauth/providers/provider-claude/cookie-authorize',
|
||||
payload,
|
||||
{ timeout: CLAUDE_COOKIE_AUTHORIZE_TIMEOUT_MS },
|
||||
)
|
||||
})
|
||||
|
||||
it('starts and polls Claude Cookie batches through the dedicated task routes', async () => {
|
||||
const payload = {
|
||||
cookies: ['sessionKey=claude-1', 'sessionKey=claude-2'],
|
||||
proxy_node_id: 'proxy-1',
|
||||
}
|
||||
|
||||
await startProviderCookieAuthorizeTask('provider-claude', payload)
|
||||
await getProviderCookieAuthorizeTaskStatus('provider-claude', 'claude-cookie-task-1')
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
'/api/admin/provider-oauth/providers/provider-claude/cookie-authorize/tasks',
|
||||
payload,
|
||||
)
|
||||
expect(getMock).toHaveBeenCalledWith(
|
||||
'/api/admin/provider-oauth/providers/provider-claude/cookie-authorize/tasks/claude-cookie-task-1',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import client from '../client'
|
||||
|
||||
const CLAUDE_COOKIE_AUTHORIZE_TIMEOUT_MS = 4 * 60 * 1000
|
||||
|
||||
export interface ProviderOAuthStartResponse {
|
||||
authorization_url: string
|
||||
redirect_uri: string
|
||||
@@ -36,6 +38,17 @@ export interface ProviderOAuthCompleteResponseWithKey {
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export interface ProviderCookieAuthorizeRequest {
|
||||
cookie: string
|
||||
name?: string
|
||||
proxy_node_id?: string
|
||||
}
|
||||
|
||||
export interface ProviderCookieAuthorizeBatchTaskRequest {
|
||||
cookies: string[]
|
||||
proxy_node_id?: string
|
||||
}
|
||||
|
||||
export interface OAuthBatchImportResultItem {
|
||||
index: number
|
||||
status: 'success' | 'error'
|
||||
@@ -47,9 +60,11 @@ export interface OAuthBatchImportResultItem {
|
||||
}
|
||||
|
||||
export type OAuthBatchImportTaskStatus = 'submitted' | 'processing' | 'completed' | 'failed'
|
||||
export type OAuthBatchImportKind = 'oauth_batch' | 'agent_identity' | 'cookie_authorize'
|
||||
|
||||
export interface OAuthBatchImportTaskStartResponse {
|
||||
task_id: string
|
||||
import_kind?: OAuthBatchImportKind
|
||||
status: OAuthBatchImportTaskStatus
|
||||
total: number
|
||||
processed: number
|
||||
@@ -63,6 +78,7 @@ export interface OAuthBatchImportTaskStartResponse {
|
||||
|
||||
export interface OAuthBatchImportTaskStatusResponse {
|
||||
task_id: string
|
||||
import_kind?: OAuthBatchImportKind
|
||||
provider_id: string
|
||||
provider_type: string
|
||||
status: OAuthBatchImportTaskStatus
|
||||
@@ -233,6 +249,39 @@ export async function completeProviderLevelOAuth(
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function authorizeProviderWithCookie(
|
||||
providerId: string,
|
||||
data: ProviderCookieAuthorizeRequest
|
||||
): Promise<ProviderOAuthCompleteResponseWithKey> {
|
||||
const resp = await client.post(
|
||||
`/api/admin/provider-oauth/providers/${providerId}/cookie-authorize`,
|
||||
data,
|
||||
{ timeout: CLAUDE_COOKIE_AUTHORIZE_TIMEOUT_MS },
|
||||
)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function startProviderCookieAuthorizeTask(
|
||||
providerId: string,
|
||||
data: ProviderCookieAuthorizeBatchTaskRequest,
|
||||
): Promise<OAuthBatchImportTaskStartResponse> {
|
||||
const resp = await client.post(
|
||||
`/api/admin/provider-oauth/providers/${providerId}/cookie-authorize/tasks`,
|
||||
data,
|
||||
)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function getProviderCookieAuthorizeTaskStatus(
|
||||
providerId: string,
|
||||
taskId: string,
|
||||
): Promise<OAuthBatchImportTaskStatusResponse> {
|
||||
const resp = await client.get(
|
||||
`/api/admin/provider-oauth/providers/${providerId}/cookie-authorize/tasks/${taskId}`,
|
||||
)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function importProviderRefreshToken(
|
||||
providerId: string,
|
||||
data: {
|
||||
|
||||
@@ -10,52 +10,65 @@
|
||||
>
|
||||
|
||||
<div
|
||||
v-if="!showManualInput"
|
||||
class="rounded-xl border-2 border-dashed transition-colors cursor-pointer"
|
||||
:class="isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-muted-foreground/40'"
|
||||
@click="fileInputRef?.click()"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleFileDrop"
|
||||
class="grid [&>*]:col-start-1 [&>*]:row-start-1"
|
||||
data-testid="json-import-mode-panels"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center py-10 gap-2">
|
||||
<div class="w-9 h-9 rounded-full bg-muted/60 flex items-center justify-center">
|
||||
<Upload class="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-xs font-medium">
|
||||
{{ localizedDropTitle }}
|
||||
</p>
|
||||
<p class="text-[11px] text-muted-foreground mt-0.5">
|
||||
{{ localizedDropHint }}
|
||||
</p>
|
||||
<div
|
||||
class="rounded-xl border-2 border-dashed transition-all duration-150 cursor-pointer"
|
||||
:class="[
|
||||
isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-muted-foreground/40',
|
||||
showManualInput ? 'opacity-0 pointer-events-none' : 'opacity-100',
|
||||
]"
|
||||
:inert="showManualInput ? '' : undefined"
|
||||
:aria-hidden="showManualInput"
|
||||
data-testid="json-import-file-panel"
|
||||
@click="fileInputRef?.click()"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleFileDrop"
|
||||
>
|
||||
<div class="flex h-full flex-col items-center justify-center py-10 gap-2">
|
||||
<div class="w-9 h-9 rounded-full bg-muted/60 flex items-center justify-center">
|
||||
<Upload class="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-xs font-medium">
|
||||
{{ localizedDropTitle }}
|
||||
</p>
|
||||
<p class="text-[11px] text-muted-foreground mt-0.5">
|
||||
{{ localizedDropHint }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<Label v-if="manualLabel">
|
||||
{{ localizedManualLabel }}
|
||||
</Label>
|
||||
<Textarea
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
:placeholder="localizedManualPlaceholder"
|
||||
:class="textareaClass"
|
||||
spellcheck="false"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
<p
|
||||
v-if="manualDescription"
|
||||
class="text-xs text-muted-foreground"
|
||||
<div
|
||||
class="space-y-1.5 transition-opacity duration-150"
|
||||
:class="showManualInput ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||
:inert="showManualInput ? undefined : ''"
|
||||
:aria-hidden="!showManualInput"
|
||||
data-testid="json-import-manual-panel"
|
||||
>
|
||||
{{ localizedManualDescription }}
|
||||
</p>
|
||||
<Label v-if="manualLabel">
|
||||
{{ localizedManualLabel }}
|
||||
</Label>
|
||||
<Textarea
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
:placeholder="localizedManualPlaceholder"
|
||||
:class="textareaClass"
|
||||
spellcheck="false"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
<p
|
||||
v-if="manualDescription"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ localizedManualDescription }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center pt-1">
|
||||
@@ -63,6 +76,7 @@
|
||||
v-if="!showManualInput"
|
||||
type="button"
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
data-testid="json-import-mode-toggle"
|
||||
@click="showManualInput = true"
|
||||
>
|
||||
{{ localizedPasteToggleText }}
|
||||
@@ -71,6 +85,7 @@
|
||||
v-else
|
||||
type="button"
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
data-testid="json-import-mode-toggle"
|
||||
@click="switchToFileMode"
|
||||
>
|
||||
{{ localizedFileToggleText }}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick, ref } from 'vue'
|
||||
|
||||
import JsonImportInput from '@/components/common/JsonImportInput.vue'
|
||||
import { createI18n } from '@/i18n'
|
||||
|
||||
describe('JsonImportInput', () => {
|
||||
it('keeps both mode panels in one layout track while switching modes', async () => {
|
||||
const value = ref('')
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
return () => h(JsonImportInput, {
|
||||
modelValue: value.value,
|
||||
'onUpdate:modelValue': (nextValue: string) => {
|
||||
value.value = nextValue
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(Host)
|
||||
app.use(createI18n())
|
||||
app.mount(root)
|
||||
|
||||
const panels = root.querySelector<HTMLElement>('[data-testid="json-import-mode-panels"]')
|
||||
const filePanel = root.querySelector<HTMLElement>('[data-testid="json-import-file-panel"]')
|
||||
const manualPanel = root.querySelector<HTMLElement>('[data-testid="json-import-manual-panel"]')
|
||||
expect(panels?.classList.contains('grid')).toBe(true)
|
||||
expect(filePanel?.getAttribute('aria-hidden')).toBe('false')
|
||||
expect(filePanel?.hasAttribute('inert')).toBe(false)
|
||||
expect(manualPanel?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(manualPanel?.getAttribute('inert')).toBe('')
|
||||
|
||||
root.querySelector<HTMLButtonElement>('[data-testid="json-import-mode-toggle"]')?.click()
|
||||
await nextTick()
|
||||
|
||||
expect(root.querySelector('[data-testid="json-import-file-panel"]')).toBe(filePanel)
|
||||
expect(root.querySelector('[data-testid="json-import-manual-panel"]')).toBe(manualPanel)
|
||||
expect(filePanel?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(filePanel?.getAttribute('inert')).toBe('')
|
||||
expect(manualPanel?.getAttribute('aria-hidden')).toBe('false')
|
||||
expect(manualPanel?.hasAttribute('inert')).toBe(false)
|
||||
|
||||
const textarea = root.querySelector<HTMLTextAreaElement>('textarea')
|
||||
if (!textarea) throw new Error('Expected manual textarea')
|
||||
textarea.value = 'credential-value'
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
expect(value.value).toBe('credential-value')
|
||||
|
||||
root.querySelector<HTMLButtonElement>('[data-testid="json-import-mode-toggle"]')?.click()
|
||||
await nextTick()
|
||||
expect(value.value).toBe('')
|
||||
expect(filePanel?.hasAttribute('inert')).toBe(false)
|
||||
expect(manualPanel?.getAttribute('inert')).toBe('')
|
||||
|
||||
app.unmount()
|
||||
root.remove()
|
||||
})
|
||||
})
|
||||
@@ -491,17 +491,21 @@ function syncGlobalModelSelection() {
|
||||
}
|
||||
|
||||
// 监听打开状态
|
||||
watch(() => props.open, async (isOpen) => {
|
||||
if (isOpen && props.providerId) {
|
||||
await loadData()
|
||||
} else {
|
||||
searchQuery.value = ''
|
||||
selectedGlobalModelIds.value = new Set()
|
||||
initialGlobalModelIds.value = new Set()
|
||||
providerKeys.value = []
|
||||
fetchingAutoMatchedModels.value = false
|
||||
}
|
||||
})
|
||||
watch(
|
||||
() => props.open,
|
||||
async (isOpen) => {
|
||||
if (isOpen && props.providerId) {
|
||||
await loadData()
|
||||
} else {
|
||||
searchQuery.value = ''
|
||||
selectedGlobalModelIds.value = new Set()
|
||||
initialGlobalModelIds.value = new Set()
|
||||
providerKeys.value = []
|
||||
fetchingAutoMatchedModels.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 加载数据
|
||||
async function loadData() {
|
||||
|
||||
@@ -708,14 +708,18 @@ async function fetchUpstreamModels() {
|
||||
}
|
||||
|
||||
// 监听打开状态
|
||||
watch(() => props.open, async (isOpen) => {
|
||||
if (isOpen) {
|
||||
initForm()
|
||||
if (props.hasAutoFetchKey) {
|
||||
await fetchUpstreamModels()
|
||||
watch(
|
||||
() => props.open,
|
||||
async (isOpen) => {
|
||||
if (isOpen) {
|
||||
initForm()
|
||||
if (props.hasAutoFetchKey) {
|
||||
await fetchUpstreamModels()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 初始化表单
|
||||
function initForm() {
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
<div
|
||||
v-if="showAuthorizationMode"
|
||||
class="grid rounded-lg border border-border p-0.5 bg-muted/30"
|
||||
:class="isCodexProvider ? 'grid-cols-3' : 'grid-cols-2'"
|
||||
:class="isCodexProvider || isClaudeCodeProvider ? 'grid-cols-3' : 'grid-cols-2'"
|
||||
>
|
||||
<button
|
||||
class="min-w-0 min-h-8 px-2 py-1.5 text-xs font-medium leading-4 rounded-md transition-all disabled:cursor-not-allowed disabled:opacity-60"
|
||||
@@ -72,17 +72,28 @@
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
]"
|
||||
:disabled="importing || creatingAgentIdentity"
|
||||
:disabled="importing || creatingAgentIdentity || cookieAuthorizing"
|
||||
@click="switchMode('oauth')"
|
||||
>
|
||||
{{ authorizationModeLabel }}
|
||||
</button>
|
||||
<button
|
||||
v-if="isClaudeCodeProvider"
|
||||
class="min-w-0 min-h-8 px-2 py-1.5 text-xs font-medium leading-4 rounded-md transition-all disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="mode === 'cookie'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'"
|
||||
:disabled="importing || creatingAgentIdentity || cookieAuthorizing"
|
||||
@click="switchMode('cookie')"
|
||||
>
|
||||
{{ legacyT('Cookie授权') }}
|
||||
</button>
|
||||
<button
|
||||
class="min-w-0 min-h-8 px-2 py-1.5 text-xs font-medium leading-4 rounded-md transition-all disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="mode === 'import'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'"
|
||||
:disabled="importing || creatingAgentIdentity"
|
||||
:disabled="importing || creatingAgentIdentity || cookieAuthorizing"
|
||||
@click="switchMode('import')"
|
||||
>
|
||||
{{ importModeLabel }}
|
||||
@@ -93,7 +104,7 @@
|
||||
:class="mode === 'agent_identity'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'"
|
||||
:disabled="importing || creatingAgentIdentity"
|
||||
:disabled="importing || creatingAgentIdentity || cookieAuthorizing"
|
||||
@click="switchMode('agent_identity')"
|
||||
>
|
||||
{{ legacyT('Agent Identity') }}
|
||||
@@ -106,6 +117,8 @@
|
||||
<div
|
||||
class="space-y-4 transition-opacity duration-150"
|
||||
:class="mode === 'oauth' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||
:inert="mode !== 'oauth' ? '' : undefined"
|
||||
:aria-hidden="mode !== 'oauth'"
|
||||
>
|
||||
<!-- Windsurf: 浏览器 session/poll 授权 -->
|
||||
<template v-if="isWindsurfProvider">
|
||||
@@ -544,8 +557,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else-if="oauth.authorization_url">
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-else-if="oauth.authorization_url"
|
||||
class="flex h-full min-h-0 flex-col gap-4"
|
||||
>
|
||||
<div class="shrink-0 space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">1</span>
|
||||
<span class="text-xs font-medium">{{ legacyT('前往授权') }}</span>
|
||||
@@ -571,29 +587,69 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<span class="flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-semibold shrink-0">2</span>
|
||||
<span class="text-xs font-medium">{{ legacyT('粘贴回调 URL') }}</span>
|
||||
<span class="text-xs font-medium">{{ oauthCallbackLabel }}</span>
|
||||
</div>
|
||||
<div class="pl-6">
|
||||
<div class="min-h-0 flex-1 pl-6">
|
||||
<Textarea
|
||||
v-model="oauth.callback_url"
|
||||
:disabled="oauthBusy"
|
||||
placeholder="http://localhost:xxx/callback?code=..."
|
||||
class="min-h-[120px] text-xs font-mono break-all !rounded-xl"
|
||||
:placeholder="oauthCallbackPlaceholder"
|
||||
class="h-full min-h-[120px] overflow-y-auto text-xs font-mono break-all !rounded-xl"
|
||||
data-testid="oauth-callback-textarea"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ===== Cookie 授权 ===== -->
|
||||
<div
|
||||
v-if="isClaudeCodeProvider"
|
||||
class="flex flex-col gap-3 justify-center transition-opacity duration-150"
|
||||
:class="mode === 'cookie' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||
:inert="mode !== 'cookie' ? '' : undefined"
|
||||
:aria-hidden="mode !== 'cookie'"
|
||||
>
|
||||
<label
|
||||
class="sr-only"
|
||||
for="claude-session-cookie"
|
||||
>
|
||||
{{ legacyT('Claude sessionKey Cookie') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<Textarea
|
||||
id="claude-session-cookie"
|
||||
v-model="cookieInput"
|
||||
:disabled="cookieAuthorizing"
|
||||
:placeholder="legacyT('每行粘贴一个 sessionKey Cookie 值或完整 Cookie 请求头,最多 20 个')"
|
||||
aria-describedby="claude-session-cookie-status"
|
||||
class="h-[200px] min-h-[200px] overflow-y-auto pb-7 text-xs font-mono break-words !rounded-xl"
|
||||
data-testid="claude-cookie-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<p
|
||||
id="claude-session-cookie-status"
|
||||
class="pointer-events-none absolute bottom-2 right-3 text-[10px]"
|
||||
:class="cookieInputOverLimit ? 'text-destructive' : 'text-muted-foreground'"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ cookieInputStatusText }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 导入授权 ===== -->
|
||||
<div
|
||||
class="flex flex-col gap-3 justify-center transition-opacity duration-150"
|
||||
:class="mode === 'import' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||
:inert="mode !== 'import' ? '' : undefined"
|
||||
:aria-hidden="mode !== 'import'"
|
||||
>
|
||||
<div
|
||||
v-if="isWindsurfProvider"
|
||||
@@ -724,6 +780,8 @@
|
||||
v-if="isCodexProvider"
|
||||
class="flex flex-col gap-3 justify-center transition-opacity duration-150"
|
||||
:class="mode === 'agent_identity' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||
:inert="mode !== 'agent_identity' ? '' : undefined"
|
||||
:aria-hidden="mode !== 'agent_identity'"
|
||||
>
|
||||
<Textarea
|
||||
v-model="agentIdentityInput"
|
||||
@@ -758,6 +816,13 @@
|
||||
>
|
||||
{{ device.completing ? legacyT('验证中...') : legacyT('验证') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="mode === 'cookie' && isClaudeCodeProvider"
|
||||
:disabled="!canAuthorizeWithCookie"
|
||||
@click="handleCookieAuthorize"
|
||||
>
|
||||
{{ cookieAuthorizeButtonText }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="mode === 'import'"
|
||||
:disabled="!canImport"
|
||||
@@ -789,7 +854,16 @@ import {
|
||||
ComboboxTrigger,
|
||||
ComboboxViewport,
|
||||
} from 'radix-vue'
|
||||
import { UserPlus, Copy, ExternalLink, Globe, AlertCircle, ShieldCheck, ChevronsUpDown, Check } from 'lucide-vue-next'
|
||||
import {
|
||||
UserPlus,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Globe,
|
||||
AlertCircle,
|
||||
ShieldCheck,
|
||||
ChevronsUpDown,
|
||||
Check,
|
||||
} from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useTotp } from '@/composables/useTotp'
|
||||
@@ -798,6 +872,9 @@ import { useI18n } from '@/i18n'
|
||||
import {
|
||||
startProviderLevelOAuth,
|
||||
completeProviderLevelOAuth,
|
||||
authorizeProviderWithCookie,
|
||||
startProviderCookieAuthorizeTask,
|
||||
getProviderCookieAuthorizeTaskStatus,
|
||||
importProviderRefreshToken,
|
||||
startBatchImportOAuthTask,
|
||||
getBatchImportOAuthTaskStatus,
|
||||
@@ -808,6 +885,7 @@ import {
|
||||
} from '@/api/endpoints'
|
||||
import type {
|
||||
OAuthBatchImportTaskStatus,
|
||||
OAuthBatchImportTaskStartResponse,
|
||||
OAuthBatchImportTaskStatusResponse,
|
||||
} from '@/api/endpoints/provider_oauth'
|
||||
import ProxyNodeSelect from './ProxyNodeSelect.vue'
|
||||
@@ -883,7 +961,7 @@ function localizedApiError(error: unknown, fallback: string): string {
|
||||
}
|
||||
|
||||
// 模式
|
||||
type DialogMode = 'oauth' | 'import' | 'agent_identity'
|
||||
type DialogMode = 'oauth' | 'cookie' | 'import' | 'agent_identity'
|
||||
const mode = ref<DialogMode>((props.providerType || '').toLowerCase() === 'grok' ? 'import' : 'oauth')
|
||||
type WindsurfImportMethod = 'email_password' | 'token_json'
|
||||
|
||||
@@ -982,6 +1060,14 @@ const windsurfAccountName = ref('')
|
||||
const agentIdentityInput = ref('')
|
||||
const creatingAgentIdentity = ref(false)
|
||||
let agentIdentityRequestId = 0
|
||||
const cookieInput = ref('')
|
||||
const cookieAuthorizing = ref(false)
|
||||
let cookieAuthorizeRequestId = 0
|
||||
const cookieAuthorizeTask = ref<OAuthBatchImportTaskStartResponse | OAuthBatchImportTaskStatusResponse | null>(null)
|
||||
const cookieAuthorizeSubmittedEntries = ref<string[]>([])
|
||||
let cookieAuthorizePollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const cookieAuthorizePolling = ref(false)
|
||||
const CLAUDE_COOKIE_BATCH_LIMIT = 20
|
||||
|
||||
const isOpen = computed(() => props.open)
|
||||
|
||||
@@ -989,6 +1075,7 @@ const isKiroProvider = computed(() => (props.providerType || '').toLowerCase() =
|
||||
const isGrokProvider = computed(() => (props.providerType || '').toLowerCase() === 'grok')
|
||||
const isWindsurfProvider = computed(() => (props.providerType || '').toLowerCase() === 'windsurf')
|
||||
const isCodexProvider = computed(() => (props.providerType || '').toLowerCase() === 'codex')
|
||||
const isClaudeCodeProvider = computed(() => (props.providerType || '').toLowerCase() === 'claude_code')
|
||||
const isDeviceBrowserProvider = computed(() => isKiroProvider.value || isWindsurfProvider.value)
|
||||
const showAuthorizationMode = computed(() => !isGrokProvider.value)
|
||||
const defaultMode = computed<DialogMode>(() => (isGrokProvider.value ? 'import' : 'oauth'))
|
||||
@@ -1017,6 +1104,16 @@ const authorizationModeLabel = computed(() => {
|
||||
return legacyT('获取授权')
|
||||
})
|
||||
|
||||
const oauthCallbackLabel = computed(() =>
|
||||
legacyT(isClaudeCodeProvider.value ? '粘贴回调 URL 或授权码' : '粘贴回调 URL')
|
||||
)
|
||||
|
||||
const oauthCallbackPlaceholder = computed(() =>
|
||||
isClaudeCodeProvider.value
|
||||
? legacyT('粘贴完整回调 URL 或授权码(code#state)')
|
||||
: 'http://localhost:xxx/callback?code=...'
|
||||
)
|
||||
|
||||
const deviceCallbackPlaceholder = computed(() =>
|
||||
isWindsurfProvider.value
|
||||
? legacyT('粘贴包含 token=...&state=... 的回调 URL;session token/apiKey 也可直接粘贴,普通 token 请用导入授权')
|
||||
@@ -1067,6 +1164,35 @@ const canImport = computed(() => {
|
||||
return importText.value.trim().length > 0 && !importing.value
|
||||
})
|
||||
|
||||
const cookieEntries = computed(() => cookieInput.value
|
||||
.split(/\r?\n/)
|
||||
.map(value => value.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
const cookieInputOverLimit = computed(() => cookieEntries.value.length > CLAUDE_COOKIE_BATCH_LIMIT)
|
||||
const canAuthorizeWithCookie = computed(() =>
|
||||
isClaudeCodeProvider.value
|
||||
&& cookieEntries.value.length > 0
|
||||
&& !cookieInputOverLimit.value
|
||||
&& !cookieAuthorizing.value
|
||||
)
|
||||
const cookieAuthorizeButtonText = computed(() => {
|
||||
if (cookieAuthorizing.value) return legacyT('授权中...')
|
||||
return cookieEntries.value.length > 1 ? legacyT('批量授权') : legacyT('授权')
|
||||
})
|
||||
const cookieInputStatusText = computed(() => {
|
||||
const task = cookieAuthorizeTask.value
|
||||
if (cookieAuthorizing.value && task) {
|
||||
return isEnglishLocale()
|
||||
? `${task.processed}/${task.total} · ${task.success} succeeded · ${task.failed} failed`
|
||||
: `进度 ${task.processed}/${task.total} · 成功 ${task.success} · 失败 ${task.failed}`
|
||||
}
|
||||
const count = cookieEntries.value.length
|
||||
if (count === 0) return legacyT('每行一个,最多 20 个')
|
||||
if (isEnglishLocale()) return `${count} entered, maximum ${CLAUDE_COOKIE_BATCH_LIMIT}`
|
||||
return `已输入 ${count} 个,最多 ${CLAUDE_COOKIE_BATCH_LIMIT} 个`
|
||||
})
|
||||
|
||||
const canCreateAgentIdentity = computed(() =>
|
||||
isCodexProvider.value
|
||||
&& agentIdentityInput.value.trim().length > 0
|
||||
@@ -1081,13 +1207,18 @@ const importDropTitle = computed(() => (
|
||||
const importDropHint = computed(() => (
|
||||
legacyT(isGrokProvider.value ? '支持 .json / .txt,可多选、批量导入' : '支持 .json / .txt,可多选')
|
||||
))
|
||||
const importManualPlaceholder = computed(() => (
|
||||
isGrokProvider.value
|
||||
? legacyT('粘贴 Grok sso/session token,支持每行一个;或粘贴包含 token、sso_token、access_token、plan_type、pool_tier 的 JSON')
|
||||
: isWindsurfProvider.value
|
||||
? legacyT('粘贴 show-auth-token Token、API key 或 JSON 内容')
|
||||
: legacyT('粘贴 Refresh Token / Access Token / Agent Identity JSON 内容')
|
||||
))
|
||||
const importManualPlaceholder = computed(() => {
|
||||
if (isGrokProvider.value) {
|
||||
return legacyT('粘贴 Grok sso/session token,支持每行一个;或粘贴包含 token、sso_token、access_token、plan_type、pool_tier 的 JSON')
|
||||
}
|
||||
if (isClaudeCodeProvider.value) {
|
||||
return legacyT('粘贴 Claude Refresh Token 或 Claude Code .credentials.json 内容')
|
||||
}
|
||||
if (isWindsurfProvider.value) {
|
||||
return legacyT('粘贴 show-auth-token Token、API key 或 JSON 内容')
|
||||
}
|
||||
return legacyT('粘贴 Refresh Token / Access Token / Agent Identity JSON 内容')
|
||||
})
|
||||
const importManualDescription = computed(() => (
|
||||
isGrokProvider.value
|
||||
? legacyT('plan_type / pool_tier 会作为账号套餐与能力特征保存,不是路由池选择。')
|
||||
@@ -1359,8 +1490,10 @@ function resetForm() {
|
||||
oauthCompleteRequestId += 1
|
||||
deviceAuthRequestId += 1
|
||||
agentIdentityRequestId += 1
|
||||
cookieAuthorizeRequestId += 1
|
||||
oauth.value = createInitialOAuthState()
|
||||
stopImportPolling()
|
||||
stopCookieAuthorizePolling()
|
||||
stopDevicePolling()
|
||||
totp.stop()
|
||||
device.value = createInitialDeviceState()
|
||||
@@ -1377,6 +1510,10 @@ function resetForm() {
|
||||
windsurfAccountName.value = ''
|
||||
agentIdentityInput.value = ''
|
||||
creatingAgentIdentity.value = false
|
||||
cookieInput.value = ''
|
||||
cookieAuthorizing.value = false
|
||||
cookieAuthorizeTask.value = null
|
||||
cookieAuthorizeSubmittedEntries.value = []
|
||||
proxyPopoverOpen.value = false
|
||||
selectedProxyNodeId.value = ''
|
||||
mode.value = defaultMode.value
|
||||
@@ -1385,8 +1522,9 @@ function resetForm() {
|
||||
function switchMode(newMode: DialogMode) {
|
||||
if (mode.value === newMode) return
|
||||
if (newMode === 'oauth' && !showAuthorizationMode.value) return
|
||||
if (newMode === 'cookie' && !isClaudeCodeProvider.value) return
|
||||
if (newMode === 'agent_identity' && !isCodexProvider.value) return
|
||||
if (importing.value || creatingAgentIdentity.value) return
|
||||
if (importing.value || creatingAgentIdentity.value || cookieAuthorizing.value) return
|
||||
|
||||
mode.value = newMode
|
||||
if (newMode === 'oauth') {
|
||||
@@ -1467,6 +1605,198 @@ async function handleCompleteOAuth() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCookieAuthorize() {
|
||||
if (!canAuthorizeWithCookie.value || !props.providerId) return
|
||||
|
||||
const entries = [...cookieEntries.value]
|
||||
const requestId = ++cookieAuthorizeRequestId
|
||||
cookieAuthorizing.value = true
|
||||
try {
|
||||
if (entries.length === 1) {
|
||||
const result = await authorizeProviderWithCookie(props.providerId, {
|
||||
cookie: entries[0],
|
||||
proxy_node_id: selectedProxyNodeId.value || undefined,
|
||||
})
|
||||
if (requestId !== cookieAuthorizeRequestId) return
|
||||
|
||||
success(getOAuthSuccessMessage('授权', result))
|
||||
emit('saved')
|
||||
handleClose()
|
||||
return
|
||||
}
|
||||
|
||||
const task = await startProviderCookieAuthorizeTask(props.providerId, {
|
||||
cookies: entries,
|
||||
proxy_node_id: selectedProxyNodeId.value || undefined,
|
||||
})
|
||||
if (requestId !== cookieAuthorizeRequestId) return
|
||||
|
||||
cookieAuthorizeTask.value = task
|
||||
cookieAuthorizeSubmittedEntries.value = entries
|
||||
scheduleCookieAuthorizePoll(task.task_id, requestId, 0)
|
||||
} catch (err: unknown) {
|
||||
if (requestId !== cookieAuthorizeRequestId) return
|
||||
const errorMessage = localizedApiError(err, 'Cookie 授权失败')
|
||||
showError(errorMessage, legacyT('错误'))
|
||||
} finally {
|
||||
if (requestId === cookieAuthorizeRequestId && !cookieAuthorizeTask.value) {
|
||||
cookieAuthorizing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopCookieAuthorizePolling() {
|
||||
if (cookieAuthorizePollTimer) {
|
||||
clearTimeout(cookieAuthorizePollTimer)
|
||||
cookieAuthorizePollTimer = null
|
||||
}
|
||||
cookieAuthorizePolling.value = false
|
||||
}
|
||||
|
||||
function scheduleCookieAuthorizePoll(taskId: string, requestId: number, delayMs = 1200) {
|
||||
stopCookieAuthorizePolling()
|
||||
cookieAuthorizePollTimer = setTimeout(() => {
|
||||
void pollCookieAuthorizeTaskStatus(taskId, requestId)
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
function cookieAuthorizeBatchSummary(task: OAuthBatchImportTaskStatusResponse): string {
|
||||
const replaced = Math.max(task.replaced_count ?? 0, 0)
|
||||
const created = Math.max(task.created_count ?? task.success - replaced, 0)
|
||||
const successDetail = isEnglishLocale()
|
||||
? `${task.success} succeeded (${created} added, ${replaced} replaced)`
|
||||
: `成功 ${task.success} 个(新增 ${created} 个,替换 ${replaced} 个)`
|
||||
|
||||
if (isEnglishLocale()) {
|
||||
return task.failed > 0
|
||||
? `Batch authorization complete: ${successDetail}, ${task.failed} failed`
|
||||
: `Batch authorization succeeded: ${successDetail}`
|
||||
}
|
||||
return task.failed > 0
|
||||
? `批量授权完成:${successDetail},失败 ${task.failed} 个`
|
||||
: `批量授权成功:${successDetail}`
|
||||
}
|
||||
|
||||
function cookieAuthorizeFailureReasons(task: OAuthBatchImportTaskStatusResponse): string[] {
|
||||
const reasons: string[] = []
|
||||
const seenIndexes = new Set<number>()
|
||||
for (const item of task.error_samples) {
|
||||
const index = item.index
|
||||
const detail = item.error?.trim()
|
||||
if (
|
||||
item.status !== 'error'
|
||||
|| !Number.isInteger(index)
|
||||
|| index < 0
|
||||
|| index >= task.total
|
||||
|| seenIndexes.has(index)
|
||||
|| !detail
|
||||
|| detail.length > 512
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
const normalized = detail.toLowerCase()
|
||||
if (
|
||||
normalized.includes('sessionkey')
|
||||
|| normalized.includes('sk-ant-')
|
||||
|| normalized.includes('cookie:')
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
seenIndexes.add(index)
|
||||
reasons.push(`#${index + 1} ${legacyT(detail)}`)
|
||||
if (reasons.length === 2) break
|
||||
}
|
||||
return reasons
|
||||
}
|
||||
|
||||
function cookieAuthorizeBatchResultMessage(task: OAuthBatchImportTaskStatusResponse): string {
|
||||
const summary = cookieAuthorizeBatchSummary(task)
|
||||
const reasons = cookieAuthorizeFailureReasons(task)
|
||||
if (reasons.length === 0) return summary
|
||||
return `${summary}${isEnglishLocale() ? '; ' : ';'}${reasons.join(isEnglishLocale() ? '; ' : ';')}`
|
||||
}
|
||||
|
||||
function failedCookieAuthorizeEntries(
|
||||
task: OAuthBatchImportTaskStatusResponse,
|
||||
entries: string[],
|
||||
): string[] {
|
||||
const failedIndexes = task.error_samples
|
||||
.filter(item => item.status === 'error' && Number.isInteger(item.index))
|
||||
.map(item => item.index)
|
||||
.filter(index => index >= 0 && index < entries.length)
|
||||
|
||||
// Keep every original line if the response is incomplete, so credentials are never discarded.
|
||||
if (new Set(failedIndexes).size !== task.failed) return entries
|
||||
return failedIndexes.map(index => entries[index])
|
||||
}
|
||||
|
||||
function handleCookieAuthorizeBatchResult(
|
||||
task: OAuthBatchImportTaskStatusResponse,
|
||||
entries: string[],
|
||||
) {
|
||||
const message = cookieAuthorizeBatchResultMessage(task)
|
||||
cookieAuthorizeTask.value = null
|
||||
cookieAuthorizeSubmittedEntries.value = []
|
||||
|
||||
if (task.failed === 0) {
|
||||
success(message)
|
||||
emit('saved')
|
||||
handleClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (task.success > 0) {
|
||||
cookieInput.value = failedCookieAuthorizeEntries(task, entries).join('\n')
|
||||
emit('saved')
|
||||
warning(message, legacyT('批量授权'))
|
||||
return
|
||||
}
|
||||
|
||||
showError(message, legacyT('错误'))
|
||||
}
|
||||
|
||||
async function pollCookieAuthorizeTaskStatus(taskId: string, requestId: number) {
|
||||
if (!props.providerId || cookieAuthorizePolling.value || requestId !== cookieAuthorizeRequestId) return
|
||||
|
||||
cookieAuthorizePolling.value = true
|
||||
try {
|
||||
const task = await getProviderCookieAuthorizeTaskStatus(props.providerId, taskId)
|
||||
if (requestId !== cookieAuthorizeRequestId) return
|
||||
cookieAuthorizeTask.value = task
|
||||
|
||||
if (task.status === 'completed') {
|
||||
stopCookieAuthorizePolling()
|
||||
cookieAuthorizing.value = false
|
||||
handleCookieAuthorizeBatchResult(task, [...cookieAuthorizeSubmittedEntries.value])
|
||||
return
|
||||
}
|
||||
|
||||
if (task.status === 'failed') {
|
||||
stopCookieAuthorizePolling()
|
||||
cookieAuthorizing.value = false
|
||||
cookieAuthorizeTask.value = null
|
||||
cookieAuthorizeSubmittedEntries.value = []
|
||||
showError(
|
||||
legacyT(task.error || task.message || 'Cookie 授权失败'),
|
||||
legacyT('Cookie 授权失败'),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
scheduleCookieAuthorizePoll(taskId, requestId)
|
||||
} catch {
|
||||
if (cookieAuthorizing.value && requestId === cookieAuthorizeRequestId) {
|
||||
scheduleCookieAuthorizePoll(taskId, requestId, 2000)
|
||||
}
|
||||
} finally {
|
||||
if (requestId === cookieAuthorizeRequestId) {
|
||||
cookieAuthorizePolling.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseImportText(text: string): {
|
||||
api_key?: string
|
||||
token?: string
|
||||
@@ -1533,6 +1863,30 @@ function parseImportText(text: string): {
|
||||
return { token: trimmed }
|
||||
}
|
||||
|
||||
if (isClaudeCodeProvider.value) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed)
|
||||
if (isObjectRecord(parsed) && isObjectRecord(parsed.claudeAiOauth)) {
|
||||
const claudeAiOauth = parsed.claudeAiOauth
|
||||
const refreshToken = normalizeStringField(claudeAiOauth.refreshToken)
|
||||
?? normalizeStringField(claudeAiOauth.refresh_token)
|
||||
const accessToken = normalizeStringField(claudeAiOauth.accessToken)
|
||||
?? normalizeStringField(claudeAiOauth.access_token)
|
||||
if (refreshToken || accessToken) {
|
||||
return {
|
||||
refresh_token: refreshToken,
|
||||
access_token: accessToken,
|
||||
expires_at: normalizeClaudeCredentialsExpiry(
|
||||
claudeAiOauth.expiresAt ?? claudeAiOauth.expires_at,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Raw Claude refresh tokens continue through the generic import path.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed)
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
@@ -1723,6 +2077,12 @@ function normalizeExpiryField(value: unknown): number | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function normalizeClaudeCredentialsExpiry(value: unknown): number | undefined {
|
||||
const expiresAt = normalizeExpiryField(value)
|
||||
if (!expiresAt) return undefined
|
||||
return expiresAt >= 10_000_000_000 ? Math.floor(expiresAt / 1000) : expiresAt
|
||||
}
|
||||
|
||||
function isLikelyJwtToken(token: string): boolean {
|
||||
const parts = token.trim().split('.')
|
||||
if (parts.length !== 3 || parts.some(part => !part)) return false
|
||||
@@ -2138,27 +2498,32 @@ async function pollDevice(withCallback = false) {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopImportPolling()
|
||||
stopCookieAuthorizePolling()
|
||||
stopDevicePolling()
|
||||
})
|
||||
|
||||
watch(() => props.open, (newOpen) => {
|
||||
if (newOpen) {
|
||||
proxyNodesStore.ensureLoaded()
|
||||
mode.value = defaultMode.value
|
||||
if (!showAuthorizationMode.value) {
|
||||
return
|
||||
}
|
||||
if (isWindsurfProvider.value) {
|
||||
device.value.auth_type = 'default'
|
||||
} else if (isKiroProvider.value) {
|
||||
void ensureKiroSocialDeviceAuth()
|
||||
watch(
|
||||
() => props.open,
|
||||
(newOpen) => {
|
||||
if (newOpen) {
|
||||
proxyNodesStore.ensureLoaded()
|
||||
mode.value = defaultMode.value
|
||||
if (!showAuthorizationMode.value) {
|
||||
return
|
||||
}
|
||||
if (isWindsurfProvider.value) {
|
||||
device.value.auth_type = 'default'
|
||||
} else if (isKiroProvider.value) {
|
||||
void ensureKiroSocialDeviceAuth()
|
||||
} else {
|
||||
initOAuth()
|
||||
}
|
||||
} else {
|
||||
initOAuth()
|
||||
resetForm()
|
||||
}
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.open, props.providerId, props.providerType] as const,
|
||||
@@ -2170,6 +2535,9 @@ watch(
|
||||
if (props.open && mode.value === 'agent_identity' && !isCodexProvider.value) {
|
||||
mode.value = defaultMode.value
|
||||
}
|
||||
if (props.open && mode.value === 'cookie' && !isClaudeCodeProvider.value) {
|
||||
mode.value = defaultMode.value
|
||||
}
|
||||
if (props.open && isWindsurfProvider.value && mode.value === 'oauth') {
|
||||
device.value.auth_type = ['default', 'google', 'github'].includes(device.value.auth_type)
|
||||
? device.value.auth_type
|
||||
|
||||
@@ -45,11 +45,8 @@
|
||||
<SelectItem value="vertex_ai">
|
||||
Vertex AI
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="claude_code"
|
||||
disabled
|
||||
>
|
||||
{{ legacyT('ClaudeCode(暂不可用)') }}
|
||||
<SelectItem value="claude_code">
|
||||
{{ legacyT('Claude Code(实验性功能)') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="codex">
|
||||
Codex
|
||||
@@ -82,7 +79,7 @@
|
||||
Vertex AI
|
||||
</SelectItem>
|
||||
<SelectItem value="claude_code">
|
||||
ClaudeCode
|
||||
{{ legacyT('Claude Code(实验性功能)') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="codex">
|
||||
Codex
|
||||
@@ -548,11 +545,6 @@ watch(() => form.value.provider_type, () => {
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!isEditMode.value && form.value.provider_type === 'claude_code') {
|
||||
showError(legacyT('ClaudeCode 提供商类型暂时禁用'), legacyT('验证失败'))
|
||||
return
|
||||
}
|
||||
|
||||
// 月卡类型必须设置周期开始时间
|
||||
if (form.value.billing_type === 'monthly_quota' && !form.value.quota_last_reset_at) {
|
||||
showError(legacyT('月卡类型必须设置周期开始时间'), legacyT('验证失败'))
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
|
||||
|
||||
import BatchAssignModelsDialog from '../BatchAssignModelsDialog.vue'
|
||||
|
||||
const globalModelMocks = vi.hoisted(() => ({
|
||||
getGlobalModels: vi.fn(),
|
||||
}))
|
||||
|
||||
const endpointMocks = vi.hoisted(() => ({
|
||||
getProviderModels: vi.fn(),
|
||||
getProviderKeys: vi.fn(),
|
||||
batchAssignModelsToProvider: vi.fn(),
|
||||
deleteModel: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/endpoints/global-models', () => globalModelMocks)
|
||||
vi.mock('@/api/endpoints', () => endpointMocks)
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/composables/useConfirm', () => ({
|
||||
useConfirm: () => ({
|
||||
confirmWarning: vi.fn().mockResolvedValue(true),
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/features/providers/composables/useUpstreamModelsCache', () => ({
|
||||
useUpstreamModelsCache: () => ({
|
||||
fetchModels: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog/Dialog.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'DialogStub',
|
||||
setup: (_props, { slots }) => () => h('section', [slots.default?.(), slots.footer?.()]),
|
||||
}),
|
||||
}
|
||||
})
|
||||
vi.mock('@/components/ui', async () => {
|
||||
const { defineComponent } = await import('vue')
|
||||
const passthrough = (name: string) => defineComponent({
|
||||
name,
|
||||
inheritAttrs: false,
|
||||
setup: (_props, { slots }) => () => slots.default?.(),
|
||||
})
|
||||
return {
|
||||
DropdownMenu: passthrough('DropdownMenuStub'),
|
||||
DropdownMenuTrigger: passthrough('DropdownMenuTriggerStub'),
|
||||
DropdownMenuContent: passthrough('DropdownMenuContentStub'),
|
||||
DropdownMenuItem: passthrough('DropdownMenuItemStub'),
|
||||
}
|
||||
})
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
async function settle() {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalModelMocks.getGlobalModels.mockReset()
|
||||
globalModelMocks.getGlobalModels.mockResolvedValue({ models: [], total: 0 })
|
||||
endpointMocks.getProviderModels.mockReset()
|
||||
endpointMocks.getProviderModels.mockResolvedValue([])
|
||||
endpointMocks.getProviderKeys.mockReset()
|
||||
endpointMocks.getProviderKeys.mockResolvedValue([])
|
||||
endpointMocks.batchAssignModelsToProvider.mockReset()
|
||||
endpointMocks.deleteModel.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
})
|
||||
|
||||
describe('BatchAssignModelsDialog loading', () => {
|
||||
it('loads model choices when lazily mounted in the open state', async () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(defineComponent({
|
||||
setup() {
|
||||
return () => h(BatchAssignModelsDialog, {
|
||||
open: true,
|
||||
providerId: 'provider-1',
|
||||
providerName: 'Provider One',
|
||||
})
|
||||
},
|
||||
}))
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
await settle()
|
||||
|
||||
expect(globalModelMocks.getGlobalModels).toHaveBeenCalledOnce()
|
||||
expect(globalModelMocks.getGlobalModels).toHaveBeenCalledWith({ limit: 1000 })
|
||||
expect(endpointMocks.getProviderModels).toHaveBeenCalledWith('provider-1')
|
||||
expect(endpointMocks.getProviderKeys).toHaveBeenCalledWith('provider-1')
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,10 @@ import ModelMappingDialog, { type AliasGroup } from '../ModelMappingDialog.vue'
|
||||
import type { Model, ProviderEndpoint } from '@/api/endpoints'
|
||||
import { updateModel } from '@/api/endpoints/models'
|
||||
|
||||
const upstreamModelMocks = vi.hoisted(() => ({
|
||||
fetchModels: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
|
||||
@@ -106,7 +110,7 @@ vi.mock('@/composables/useToast', () => ({
|
||||
|
||||
vi.mock('../../composables/useUpstreamModelsCache', () => ({
|
||||
useUpstreamModelsCache: () => ({
|
||||
fetchModels: vi.fn(),
|
||||
fetchModels: upstreamModelMocks.fetchModels,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -114,6 +118,7 @@ const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(updateModel).mockClear()
|
||||
upstreamModelMocks.fetchModels.mockReset()
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
@@ -121,6 +126,36 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('ModelMappingDialog', () => {
|
||||
it('initializes upstream models when lazily mounted in the open state', async () => {
|
||||
upstreamModelMocks.fetchModels.mockResolvedValue({
|
||||
models: [],
|
||||
error: null,
|
||||
warning: null,
|
||||
})
|
||||
const model = {
|
||||
id: 'model-1',
|
||||
provider_model_name: 'provider-model-1',
|
||||
provider_model_mappings: [],
|
||||
} as Model
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(defineComponent({
|
||||
setup() {
|
||||
return () => h(ModelMappingDialog, {
|
||||
open: true,
|
||||
providerId: 'provider-1',
|
||||
models: [model],
|
||||
hasAutoFetchKey: true,
|
||||
})
|
||||
},
|
||||
}))
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
await vi.waitFor(() => expect(upstreamModelMocks.fetchModels).toHaveBeenCalledTimes(1))
|
||||
expect(upstreamModelMocks.fetchModels).toHaveBeenCalledWith('provider-1')
|
||||
})
|
||||
|
||||
it('offers session compaction only for an explicitly selected Responses endpoint', async () => {
|
||||
const chatEndpoint = {
|
||||
id: 'endpoint-chat',
|
||||
|
||||
+320
-9
@@ -5,6 +5,9 @@ import OAuthAccountDialog from '@/features/providers/components/OAuthAccountDial
|
||||
const endpointMocks = vi.hoisted(() => ({
|
||||
startProviderLevelOAuth: vi.fn(),
|
||||
completeProviderLevelOAuth: vi.fn(),
|
||||
authorizeProviderWithCookie: vi.fn(),
|
||||
startProviderCookieAuthorizeTask: vi.fn(),
|
||||
getProviderCookieAuthorizeTaskStatus: vi.fn(),
|
||||
importProviderRefreshToken: vi.fn(),
|
||||
startBatchImportOAuthTask: vi.fn(),
|
||||
getBatchImportOAuthTaskStatus: vi.fn(),
|
||||
@@ -46,9 +49,11 @@ vi.mock('@/components/ui', async () => {
|
||||
modelValue: Boolean,
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
return () => props.modelValue
|
||||
? h('section', [slots.headerActions?.(), slots.default?.(), slots.footer?.()])
|
||||
: null
|
||||
return () => {
|
||||
if (!props.modelValue) return null
|
||||
const headerActions = slots['header-actions'] ?? slots.headerActions
|
||||
return h('section', [headerActions?.(), slots.default?.(), slots.footer?.()])
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -184,6 +189,7 @@ vi.mock('@/components/common/JsonImportInput.vue', async () => {
|
||||
h('p', props.pasteToggleText),
|
||||
h('p', props.fileToggleText),
|
||||
h('textarea', {
|
||||
'data-testid': 'import-textarea',
|
||||
placeholder: props.manualPlaceholder,
|
||||
value: props.modelValue,
|
||||
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLTextAreaElement).value),
|
||||
@@ -195,14 +201,24 @@ vi.mock('@/components/common/JsonImportInput.vue', async () => {
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/Label.vue', () => ({}))
|
||||
vi.mock('./ProxyNodeSelect.vue', () => ({}))
|
||||
vi.mock('@/features/providers/components/ProxyNodeSelect.vue', async () => {
|
||||
vi.mock('../ProxyNodeSelect.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'ProxyNodeSelectStub',
|
||||
setup() {
|
||||
return () => h('div')
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(_, { emit }) {
|
||||
return () => h('button', {
|
||||
type: 'button',
|
||||
'data-testid': 'proxy-node-select',
|
||||
onClick: () => emit('update:modelValue', 'proxy-node-1'),
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -288,17 +304,20 @@ function getExactButton(root: HTMLElement, text: string) {
|
||||
}
|
||||
|
||||
function getImportTextarea(root: HTMLElement) {
|
||||
const textarea = root.querySelector('textarea')
|
||||
const textarea = root.querySelector('[data-testid="import-textarea"]')
|
||||
if (!(textarea instanceof HTMLTextAreaElement)) {
|
||||
throw new Error('Expected import textarea to exist')
|
||||
}
|
||||
return textarea
|
||||
}
|
||||
|
||||
describe('OAuthAccountDialog Grok import', () => {
|
||||
describe('OAuthAccountDialog authorization and import', () => {
|
||||
beforeEach(() => {
|
||||
endpointMocks.startProviderLevelOAuth.mockReset()
|
||||
endpointMocks.completeProviderLevelOAuth.mockReset()
|
||||
endpointMocks.authorizeProviderWithCookie.mockReset()
|
||||
endpointMocks.startProviderCookieAuthorizeTask.mockReset()
|
||||
endpointMocks.getProviderCookieAuthorizeTaskStatus.mockReset()
|
||||
endpointMocks.importProviderRefreshToken.mockReset()
|
||||
endpointMocks.startBatchImportOAuthTask.mockReset()
|
||||
endpointMocks.getBatchImportOAuthTaskStatus.mockReset()
|
||||
@@ -309,6 +328,28 @@ describe('OAuthAccountDialog Grok import', () => {
|
||||
toastMocks.warning.mockReset()
|
||||
toastMocks.error.mockReset()
|
||||
|
||||
endpointMocks.startProviderLevelOAuth.mockResolvedValue({
|
||||
authorization_url: 'https://claude.ai/oauth/authorize',
|
||||
redirect_uri: 'https://platform.claude.com/oauth/code/callback',
|
||||
provider_type: 'claude_code',
|
||||
instructions: '',
|
||||
})
|
||||
endpointMocks.authorizeProviderWithCookie.mockResolvedValue({
|
||||
key_id: 'key-claude-cookie',
|
||||
provider_type: 'claude_code',
|
||||
has_refresh_token: true,
|
||||
email: 'claude@example.com',
|
||||
replaced: false,
|
||||
})
|
||||
endpointMocks.startProviderCookieAuthorizeTask.mockResolvedValue({
|
||||
task_id: 'claude-cookie-task-1',
|
||||
status: 'submitted',
|
||||
total: 2,
|
||||
processed: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
progress_percent: 0,
|
||||
})
|
||||
endpointMocks.importProviderRefreshToken.mockResolvedValue({
|
||||
provider_type: 'grok',
|
||||
has_refresh_token: false,
|
||||
@@ -331,6 +372,7 @@ describe('OAuthAccountDialog Grok import', () => {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('opens Grok in import mode without starting unsupported OAuth', async () => {
|
||||
@@ -344,6 +386,275 @@ describe('OAuthAccountDialog Grok import', () => {
|
||||
expect(getButton(root, '导入账号')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows Claude authorization modes in the required order', async () => {
|
||||
const root = mountDialog('claude_code')
|
||||
await settle()
|
||||
await settle()
|
||||
|
||||
const modeLabels = Array.from(root.querySelectorAll('button'))
|
||||
.map(button => button.textContent?.trim())
|
||||
.filter(label => ['获取授权', 'Cookie授权', '导入授权'].includes(label || ''))
|
||||
|
||||
expect(modeLabels).toEqual(['获取授权', 'Cookie授权', '导入授权'])
|
||||
expect(Array.from(root.querySelectorAll<HTMLTextAreaElement>('textarea')).map(
|
||||
textarea => textarea.placeholder,
|
||||
)).toContain('粘贴完整回调 URL 或授权码(code#state)')
|
||||
|
||||
const callbackTextarea = root.querySelector<HTMLTextAreaElement>(
|
||||
'[data-testid="oauth-callback-textarea"]',
|
||||
)
|
||||
expect(callbackTextarea?.classList.contains('h-full')).toBe(true)
|
||||
expect(callbackTextarea?.classList.contains('min-h-[120px]')).toBe(true)
|
||||
expect(callbackTextarea?.parentElement?.classList.contains('flex-1')).toBe(true)
|
||||
|
||||
const cookieInput = root.querySelector<HTMLTextAreaElement>(
|
||||
'textarea[placeholder="每行粘贴一个 sessionKey Cookie 值或完整 Cookie 请求头,最多 20 个"]',
|
||||
)
|
||||
const cookiePanel = cookieInput?.closest('[inert]')
|
||||
expect(cookiePanel?.getAttribute('aria-hidden')).toBe('true')
|
||||
})
|
||||
|
||||
it('keeps Cookie authorization unavailable for non-Claude providers', async () => {
|
||||
const root = mountDialog('codex')
|
||||
await settle()
|
||||
|
||||
expect(getExactButton(root, 'Cookie授权')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('authorizes a Claude account with a cookie and selected proxy node', async () => {
|
||||
const root = mountDialog('claude_code')
|
||||
await settle()
|
||||
|
||||
getExactButton(root, 'Cookie授权')?.click()
|
||||
await settle()
|
||||
|
||||
const cookieInput = root.querySelector<HTMLTextAreaElement>(
|
||||
'textarea[placeholder="每行粘贴一个 sessionKey Cookie 值或完整 Cookie 请求头,最多 20 个"]',
|
||||
)
|
||||
if (!cookieInput) throw new Error('Expected Claude cookie input to exist')
|
||||
expect(cookieInput.classList.contains('min-h-[200px]')).toBe(true)
|
||||
expect(cookieInput.classList.contains('h-[200px]')).toBe(true)
|
||||
expect(cookieInput.parentElement?.classList.contains('relative')).toBe(true)
|
||||
expect(root.querySelector('#claude-session-cookie-status')?.classList.contains('absolute')).toBe(true)
|
||||
expect(cookieInput.style.getPropertyValue('-webkit-text-security')).toBe('')
|
||||
expect(cookieInput.closest('[aria-hidden="true"]')).toBeNull()
|
||||
expect(cookieInput.closest('[inert]')).toBeNull()
|
||||
expect(root.querySelector('[data-testid="cookie-visibility-toggle"]')).toBeNull()
|
||||
|
||||
const authorizeButton = getExactButton(root, '授权')
|
||||
expect(authorizeButton?.disabled).toBe(true)
|
||||
|
||||
const proxyNodeSelect = root.querySelector<HTMLButtonElement>('[data-testid="proxy-node-select"]')
|
||||
expect(proxyNodeSelect).toBeTruthy()
|
||||
proxyNodeSelect?.click()
|
||||
await settle()
|
||||
cookieInput.value = 'Cookie: sessionKey=claude-session-key'
|
||||
cookieInput.dispatchEvent(new Event('input'))
|
||||
await settle()
|
||||
|
||||
expect(authorizeButton?.disabled).toBe(false)
|
||||
authorizeButton?.click()
|
||||
await settle()
|
||||
|
||||
expect(endpointMocks.authorizeProviderWithCookie).toHaveBeenCalledWith('provider-1', {
|
||||
cookie: 'Cookie: sessionKey=claude-session-key',
|
||||
proxy_node_id: 'proxy-node-1',
|
||||
})
|
||||
expect(toastMocks.success).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('authorizes multiple Claude cookies through a task and keeps only failed lines', async () => {
|
||||
vi.useFakeTimers()
|
||||
endpointMocks.getProviderCookieAuthorizeTaskStatus.mockResolvedValueOnce({
|
||||
task_id: 'claude-cookie-task-1',
|
||||
provider_id: 'provider-1',
|
||||
provider_type: 'claude_code',
|
||||
status: 'completed',
|
||||
total: 3,
|
||||
processed: 3,
|
||||
success: 2,
|
||||
failed: 1,
|
||||
created_count: 1,
|
||||
replaced_count: 1,
|
||||
progress_percent: 100,
|
||||
message: null,
|
||||
error: null,
|
||||
error_samples: [{ index: 1, status: 'error', error: 'expired cookie' }],
|
||||
created_at: 1,
|
||||
finished_at: 2,
|
||||
updated_at: 2,
|
||||
})
|
||||
const root = mountDialog('claude_code')
|
||||
await settle()
|
||||
|
||||
getExactButton(root, 'Cookie授权')?.click()
|
||||
await settle()
|
||||
const cookieInput = root.querySelector<HTMLTextAreaElement>('[data-testid="claude-cookie-input"]')
|
||||
if (!cookieInput) throw new Error('Expected Claude cookie input to exist')
|
||||
cookieInput.value = [
|
||||
'sessionKey=claude-session-1',
|
||||
'',
|
||||
'Cookie: sessionKey=expired-session',
|
||||
'sessionKey=claude-session-3',
|
||||
].join('\n')
|
||||
cookieInput.dispatchEvent(new Event('input'))
|
||||
await settle()
|
||||
|
||||
const batchButton = getExactButton(root, '批量授权')
|
||||
expect(batchButton?.disabled).toBe(false)
|
||||
batchButton?.click()
|
||||
await settle()
|
||||
|
||||
expect(endpointMocks.startProviderCookieAuthorizeTask).toHaveBeenCalledWith('provider-1', {
|
||||
cookies: [
|
||||
'sessionKey=claude-session-1',
|
||||
'Cookie: sessionKey=expired-session',
|
||||
'sessionKey=claude-session-3',
|
||||
],
|
||||
proxy_node_id: undefined,
|
||||
})
|
||||
expect(getExactButton(root, '授权中...')).toBeTruthy()
|
||||
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
await settle()
|
||||
|
||||
expect(endpointMocks.getProviderCookieAuthorizeTaskStatus).toHaveBeenCalledWith(
|
||||
'provider-1',
|
||||
'claude-cookie-task-1',
|
||||
)
|
||||
expect(cookieInput.value).toBe('Cookie: sessionKey=expired-session')
|
||||
expect(toastMocks.warning).toHaveBeenCalledWith(
|
||||
'批量授权完成:成功 2 个(新增 1 个,替换 1 个),失败 1 个;#2 expired cookie',
|
||||
'批量授权',
|
||||
)
|
||||
expect(toastMocks.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps all Claude cookie lines when a batch task has no successes', async () => {
|
||||
vi.useFakeTimers()
|
||||
endpointMocks.getProviderCookieAuthorizeTaskStatus.mockResolvedValueOnce({
|
||||
task_id: 'claude-cookie-task-1',
|
||||
provider_id: 'provider-1',
|
||||
provider_type: 'claude_code',
|
||||
status: 'completed',
|
||||
total: 4,
|
||||
processed: 4,
|
||||
success: 0,
|
||||
failed: 4,
|
||||
created_count: 0,
|
||||
replaced_count: 0,
|
||||
progress_percent: 100,
|
||||
message: null,
|
||||
error: null,
|
||||
error_samples: [
|
||||
{ index: 0, status: 'error', error: 'sessionKey=must-not-leak' },
|
||||
{ index: 1, status: 'error', error: 'invalid cookie' },
|
||||
{ index: 2, status: 'error', error: 'expired cookie' },
|
||||
{ index: 3, status: 'error', error: 'third safe reason' },
|
||||
],
|
||||
created_at: 1,
|
||||
finished_at: 2,
|
||||
updated_at: 2,
|
||||
})
|
||||
const root = mountDialog('claude_code')
|
||||
await settle()
|
||||
|
||||
getExactButton(root, 'Cookie授权')?.click()
|
||||
await settle()
|
||||
const cookieInput = root.querySelector<HTMLTextAreaElement>('[data-testid="claude-cookie-input"]')
|
||||
if (!cookieInput) throw new Error('Expected Claude cookie input to exist')
|
||||
const originalInput = [
|
||||
'sessionKey=secret',
|
||||
'sessionKey=invalid',
|
||||
'sessionKey=expired',
|
||||
'sessionKey=other',
|
||||
].join('\n')
|
||||
cookieInput.value = originalInput
|
||||
cookieInput.dispatchEvent(new Event('input'))
|
||||
await settle()
|
||||
getExactButton(root, '批量授权')?.click()
|
||||
await settle()
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
await settle()
|
||||
|
||||
expect(cookieInput.value).toBe(originalInput)
|
||||
expect(toastMocks.error).toHaveBeenCalledWith(
|
||||
'批量授权完成:成功 0 个(新增 0 个,替换 0 个),失败 4 个;#2 invalid cookie;#3 expired cookie',
|
||||
'错误',
|
||||
)
|
||||
expect(toastMocks.error.mock.calls.at(-1)?.[0]).not.toContain('must-not-leak')
|
||||
expect(toastMocks.error.mock.calls.at(-1)?.[0]).not.toContain('third safe reason')
|
||||
expect(toastMocks.warning).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks Claude cookie batches over the 20-account limit', async () => {
|
||||
const root = mountDialog('claude_code')
|
||||
await settle()
|
||||
|
||||
getExactButton(root, 'Cookie授权')?.click()
|
||||
await settle()
|
||||
const cookieInput = root.querySelector<HTMLTextAreaElement>('[data-testid="claude-cookie-input"]')
|
||||
if (!cookieInput) throw new Error('Expected Claude cookie input to exist')
|
||||
cookieInput.value = Array.from({ length: 21 }, (_, index) => `sessionKey=claude-${index}`).join('\n')
|
||||
cookieInput.dispatchEvent(new Event('input'))
|
||||
await settle()
|
||||
|
||||
expect(getExactButton(root, '批量授权')?.disabled).toBe(true)
|
||||
expect(root.querySelector('#claude-session-cookie-status')?.textContent?.trim())
|
||||
.toBe('已输入 21 个,最多 20 个')
|
||||
expect(endpointMocks.startProviderCookieAuthorizeTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses a Claude-specific import credential placeholder', async () => {
|
||||
const root = mountDialog('claude_code')
|
||||
await settle()
|
||||
|
||||
getExactButton(root, '导入授权')?.click()
|
||||
await settle()
|
||||
|
||||
const textarea = root.querySelector<HTMLTextAreaElement>(
|
||||
'textarea[placeholder="粘贴 Claude Refresh Token 或 Claude Code .credentials.json 内容"]',
|
||||
)
|
||||
expect(textarea).toBeTruthy()
|
||||
})
|
||||
|
||||
it('imports only Claude OAuth credentials from a Claude Code credentials file', async () => {
|
||||
const root = mountDialog('claude_code')
|
||||
await settle()
|
||||
|
||||
getExactButton(root, '导入授权')?.click()
|
||||
await settle()
|
||||
|
||||
const textarea = root.querySelector<HTMLTextAreaElement>(
|
||||
'textarea[placeholder="粘贴 Claude Refresh Token 或 Claude Code .credentials.json 内容"]',
|
||||
)
|
||||
if (!textarea) throw new Error('Expected Claude credentials import textarea to exist')
|
||||
textarea.value = JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'claude-access-token',
|
||||
refreshToken: 'claude-refresh-token',
|
||||
expiresAt: 4_102_444_800_000,
|
||||
scopes: ['user:inference'],
|
||||
},
|
||||
mcpOAuth: {
|
||||
accessToken: 'mcp-access-token-must-not-be-imported',
|
||||
refreshToken: 'mcp-refresh-token-must-not-be-imported',
|
||||
},
|
||||
})
|
||||
textarea.dispatchEvent(new Event('input'))
|
||||
await settle()
|
||||
|
||||
getExactButton(root, '导入')?.click()
|
||||
await settle()
|
||||
|
||||
expect(endpointMocks.importProviderRefreshToken).toHaveBeenCalledWith('provider-1', {
|
||||
access_token: 'claude-access-token',
|
||||
refresh_token: 'claude-refresh-token',
|
||||
expires_at: 4_102_444_800,
|
||||
proxy_node_id: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('maps a single Grok JSON token into account metadata import payload', async () => {
|
||||
const root = mountDialog('grok')
|
||||
await settle()
|
||||
|
||||
+75
@@ -20,6 +20,49 @@ vi.mock('@/api/endpoints', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/components/ui')>()
|
||||
const { defineComponent, h } = await import('vue')
|
||||
const passthrough = (name: string) => defineComponent({
|
||||
name,
|
||||
setup: (_props, { slots }) => () => slots.default?.(),
|
||||
})
|
||||
|
||||
return {
|
||||
...actual,
|
||||
Select: defineComponent({
|
||||
name: 'SelectStub',
|
||||
props: {
|
||||
modelValue: String,
|
||||
disabled: Boolean,
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup: (props, { emit, slots }) => () => h('select', {
|
||||
value: props.modelValue,
|
||||
disabled: props.disabled,
|
||||
onChange: (event: Event) => emit(
|
||||
'update:modelValue',
|
||||
(event.target as HTMLSelectElement).value,
|
||||
),
|
||||
}, slots.default?.()),
|
||||
}),
|
||||
SelectTrigger: passthrough('SelectTriggerStub'),
|
||||
SelectValue: passthrough('SelectValueStub'),
|
||||
SelectContent: passthrough('SelectContentStub'),
|
||||
SelectItem: defineComponent({
|
||||
name: 'SelectItemStub',
|
||||
props: {
|
||||
value: { type: String, required: true },
|
||||
disabled: Boolean,
|
||||
},
|
||||
setup: (props, { slots }) => () => h('option', {
|
||||
value: props.value,
|
||||
disabled: props.disabled,
|
||||
}, slots.default?.()),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
success: vi.fn(),
|
||||
@@ -175,3 +218,35 @@ describe('ProviderFormDialog transfer limits', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProviderFormDialog provider types', () => {
|
||||
it('creates an experimental Claude Code provider from the add dialog', async () => {
|
||||
mountDialog(null)
|
||||
await settle()
|
||||
|
||||
const providerTypeSelect = [...document.body.querySelectorAll<HTMLSelectElement>('select')]
|
||||
.find(select => select.querySelector('option[value="claude_code"]'))
|
||||
const claudeCodeOption = providerTypeSelect?.querySelector<HTMLOptionElement>(
|
||||
'option[value="claude_code"]',
|
||||
)
|
||||
|
||||
expect(claudeCodeOption?.disabled).toBe(false)
|
||||
expect(claudeCodeOption?.textContent?.trim()).toBe('Claude Code(实验性功能)')
|
||||
|
||||
await setInput('#name', 'Claude Code Provider')
|
||||
if (!providerTypeSelect) throw new Error('Missing provider type select')
|
||||
providerTypeSelect.value = 'claude_code'
|
||||
providerTypeSelect.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
clickButton('创建')
|
||||
await settle()
|
||||
|
||||
expect(endpointMocks.createProvider).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Claude Code Provider',
|
||||
provider_type: 'claude_code',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -304,6 +304,7 @@
|
||||
|
||||
<!-- 添加/编辑映射对话框 -->
|
||||
<ModelMappingDialog
|
||||
v-if="dialogOpen"
|
||||
v-model:open="dialogOpen"
|
||||
:provider-id="provider.id"
|
||||
:models="models"
|
||||
|
||||
@@ -1401,7 +1401,7 @@ const legacyExactEnglishMessages: Record<string, string> = {
|
||||
'例如: OpenAI 主账号': 'Example: OpenAI primary account',
|
||||
'提供商类型': 'Provider type',
|
||||
'请选择': 'Select',
|
||||
'ClaudeCode(暂不可用)': 'ClaudeCode (temporarily unavailable)',
|
||||
'Claude Code(实验性功能)': 'Claude Code (experimental feature)',
|
||||
'反代使用固定端点且不可修改': 'Reverse proxy providers use fixed endpoints that cannot be modified.',
|
||||
'主站链接': 'Website URL',
|
||||
'https://example.com(可选)': 'https://example.com (optional)',
|
||||
@@ -1427,7 +1427,6 @@ const legacyExactEnglishMessages: Record<string, string> = {
|
||||
'启用后仅对 Kiro 请求模拟 prompt cache 读写计量。': 'When enabled, only Kiro requests simulate prompt cache read/write accounting.',
|
||||
'请前往模块管理-敏感信息保护中配置详细规则。': 'Configure detailed rules in Modules - Sensitive information protection.',
|
||||
'验证失败': 'Validation failed',
|
||||
'ClaudeCode 提供商类型暂时禁用': 'ClaudeCode provider type is temporarily disabled',
|
||||
'月卡类型必须设置周期开始时间': 'Monthly quota billing requires a cycle start time',
|
||||
'周期开始时间必须是合法时间': 'Cycle start time must be a valid time',
|
||||
'过期时间必须是合法时间': 'Expiration time must be a valid time',
|
||||
@@ -1585,6 +1584,14 @@ const legacyExactEnglishMessages: Record<string, string> = {
|
||||
'添加账号': 'Add account',
|
||||
'授权': 'Authorization',
|
||||
'获取授权': 'Authorize',
|
||||
'Cookie授权': 'Cookie authorization',
|
||||
'Claude sessionKey Cookie': 'Claude sessionKey cookie',
|
||||
'粘贴 sessionKey Cookie 值或完整 Cookie 请求头': 'Paste a sessionKey cookie value or a complete Cookie header',
|
||||
'每行粘贴一个 sessionKey Cookie 值或完整 Cookie 请求头,最多 20 个': 'Paste one sessionKey cookie value or complete Cookie header per line, up to 20',
|
||||
'每行一个,最多 20 个': 'One per line, up to 20',
|
||||
'批量授权': 'Batch authorize',
|
||||
'Cookie 授权失败': 'Cookie authorization failed',
|
||||
'授权中...': 'Authorizing...',
|
||||
'浏览器登录': 'Browser sign-in',
|
||||
'设备授权': 'Device authorization',
|
||||
'导入授权': 'Import authorization',
|
||||
@@ -1612,6 +1619,8 @@ const legacyExactEnglishMessages: Record<string, string> = {
|
||||
'打开': 'Open',
|
||||
'开始': 'Start',
|
||||
'粘贴回调 URL': 'Paste callback URL',
|
||||
'粘贴回调 URL 或授权码': 'Paste callback URL or authorization code',
|
||||
'粘贴完整回调 URL 或授权码(code#state)': 'Paste the full callback URL or authorization code (code#state)',
|
||||
'粘贴回调 URL 或 token': 'Paste callback URL or token',
|
||||
'粘贴包含 token=...&state=... 的回调 URL;session token/apiKey 也可直接粘贴,普通 token 请用导入授权': 'Paste a callback URL containing token=...&state=...; session token/apiKey can also be pasted directly. Use import authorization for regular tokens.',
|
||||
'在浏览器中完成授权': 'Complete authorization in the browser',
|
||||
@@ -1633,6 +1642,7 @@ const legacyExactEnglishMessages: Record<string, string> = {
|
||||
'支持 .json / .txt,可多选': 'Supports .json / .txt and multiple selection',
|
||||
'粘贴 Grok sso/session token,支持每行一个;或粘贴包含 token、sso_token、access_token、plan_type、pool_tier 的 JSON': 'Paste Grok sso/session tokens, one per line, or paste JSON containing token, sso_token, access_token, plan_type, and pool_tier.',
|
||||
'粘贴 show-auth-token Token、API key 或 JSON 内容': 'Paste a show-auth-token token, API key, or JSON content',
|
||||
'粘贴 Claude Refresh Token 或 Claude Code .credentials.json 内容': 'Paste a Claude refresh token or Claude Code .credentials.json',
|
||||
'粘贴 Refresh Token / Access Token / Agent Identity JSON 内容': 'Paste a Refresh Token, Access Token, or Agent Identity JSON',
|
||||
'plan_type / pool_tier 会作为账号套餐与能力特征保存,不是路由池选择。': 'plan_type / pool_tier are saved as account plan and capability traits, not as routing pool selection.',
|
||||
'或手动粘贴 Grok Token': 'Or manually paste a Grok token',
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/config/demo', () => ({
|
||||
isDemoMode: () => true,
|
||||
DEMO_ACCOUNTS: {
|
||||
admin: { email: 'admin@demo.aether.io', password: 'demo123' },
|
||||
user: { email: 'user@demo.aether.io', password: 'demo123' },
|
||||
},
|
||||
}))
|
||||
|
||||
import { handleMockRequest, setMockUserToken } from '../handler'
|
||||
|
||||
describe('Claude Cookie authorization demo contracts', () => {
|
||||
beforeEach(() => {
|
||||
setMockUserToken('demo-access-token-admin')
|
||||
})
|
||||
|
||||
it('keeps the single Cookie authorization response compatible', async () => {
|
||||
const response = await handleMockRequest({
|
||||
method: 'POST',
|
||||
url: '/api/admin/provider-oauth/providers/provider-claude/cookie-authorize',
|
||||
data: JSON.stringify({ cookie: 'sessionKey=single-secret' }),
|
||||
})
|
||||
|
||||
expect(response?.data).toMatchObject({
|
||||
provider_type: 'claude_code',
|
||||
has_refresh_token: true,
|
||||
})
|
||||
expect(JSON.stringify(response?.data)).not.toContain('single-secret')
|
||||
})
|
||||
|
||||
it('starts and reads a Cookie batch task without returning Cookie values', async () => {
|
||||
const startResponse = await handleMockRequest({
|
||||
method: 'POST',
|
||||
url: '/api/admin/provider-oauth/providers/provider-claude/cookie-authorize/tasks',
|
||||
data: JSON.stringify({
|
||||
cookies: ['sessionKey=success-secret', 'sessionKey=mock-fail-secret'],
|
||||
}),
|
||||
})
|
||||
const start = startResponse?.data as { task_id: string }
|
||||
|
||||
expect(startResponse?.data).toMatchObject({
|
||||
import_kind: 'cookie_authorize',
|
||||
status: 'submitted',
|
||||
total: 2,
|
||||
processed: 0,
|
||||
})
|
||||
|
||||
const statusResponse = await handleMockRequest({
|
||||
method: 'GET',
|
||||
url: `/api/admin/provider-oauth/providers/provider-claude/cookie-authorize/tasks/${start.task_id}`,
|
||||
})
|
||||
|
||||
expect(statusResponse?.data).toMatchObject({
|
||||
task_id: start.task_id,
|
||||
provider_id: 'provider-claude',
|
||||
provider_type: 'claude_code',
|
||||
import_kind: 'cookie_authorize',
|
||||
status: 'completed',
|
||||
total: 2,
|
||||
success: 1,
|
||||
failed: 1,
|
||||
error_samples: [{ index: 1, status: 'error' }],
|
||||
})
|
||||
expect(JSON.stringify(statusResponse?.data)).not.toContain('success-secret')
|
||||
expect(JSON.stringify(statusResponse?.data)).not.toContain('mock-fail-secret')
|
||||
})
|
||||
})
|
||||
@@ -3100,6 +3100,85 @@ registerDynamicRoute('POST', '/api/admin/provider-oauth/providers/:providerId/co
|
||||
})
|
||||
})
|
||||
|
||||
const mockClaudeCookieAuthorizeTasks = new Map<string, Record<string, unknown>>()
|
||||
let mockClaudeCookieAuthorizeTaskSequence = 0
|
||||
|
||||
registerDynamicRoute('POST', '/api/admin/provider-oauth/providers/:providerId/cookie-authorize/tasks', async (config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
const body = JSON.parse(config.data || '{}')
|
||||
const cookies = Array.isArray(body.cookies)
|
||||
? body.cookies.filter((cookie: unknown): cookie is string => typeof cookie === 'string' && cookie.trim().length > 0)
|
||||
: []
|
||||
const errorSamples = cookies.flatMap((cookie: string, index: number) => (
|
||||
cookie.includes('mock-fail')
|
||||
? [{ index, status: 'error', error: '演示模式:Cookie 授权失败', replaced: false }]
|
||||
: []
|
||||
))
|
||||
const failed = errorSamples.length
|
||||
const success = cookies.length - failed
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const taskId = `claude-cookie-${Date.now()}-${++mockClaudeCookieAuthorizeTaskSequence}`
|
||||
mockClaudeCookieAuthorizeTasks.set(taskId, {
|
||||
task_id: taskId,
|
||||
provider_id: params.providerId,
|
||||
provider_type: 'claude_code',
|
||||
import_kind: 'cookie_authorize',
|
||||
status: 'completed',
|
||||
total: cookies.length,
|
||||
processed: cookies.length,
|
||||
success,
|
||||
failed,
|
||||
created_count: success,
|
||||
replaced_count: 0,
|
||||
progress_percent: 100,
|
||||
message: null,
|
||||
error: null,
|
||||
error_samples: errorSamples,
|
||||
created_at: now,
|
||||
started_at: now,
|
||||
finished_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
return createMockResponse({
|
||||
task_id: taskId,
|
||||
import_kind: 'cookie_authorize',
|
||||
status: 'submitted',
|
||||
total: cookies.length,
|
||||
processed: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
created_count: 0,
|
||||
replaced_count: 0,
|
||||
progress_percent: 0,
|
||||
message: '任务已提交',
|
||||
})
|
||||
})
|
||||
|
||||
registerDynamicRoute('GET', '/api/admin/provider-oauth/providers/:providerId/cookie-authorize/tasks/:taskId', async (_config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
const task = mockClaudeCookieAuthorizeTasks.get(params.taskId)
|
||||
if (!task || task.provider_id !== params.providerId) {
|
||||
throw { response: createMockResponse({ detail: 'Cookie 授权任务不存在' }, 404) }
|
||||
}
|
||||
return createMockResponse(task)
|
||||
})
|
||||
|
||||
registerDynamicRoute('POST', '/api/admin/provider-oauth/providers/:providerId/cookie-authorize', async (config, _params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
const body = JSON.parse(config.data || '{}')
|
||||
return createMockResponse({
|
||||
key_id: `key-claude-cookie-${Date.now()}`,
|
||||
provider_type: 'claude_code',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 24 * 3600,
|
||||
has_refresh_token: true,
|
||||
email: body.name ? `${body.name}@demo.dev` : 'claude-oauth-demo@aether.dev'
|
||||
})
|
||||
})
|
||||
|
||||
registerDynamicRoute('POST', '/api/admin/provider-oauth/providers/:providerId/import-refresh-token', async (config, _params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
|
||||
Reference in New Issue
Block a user