Compare commits

...

1 Commits

Author SHA1 Message Date
Supra4E8C
8ca6d31a26 feat(oauth): add vertex json login via vertex/import 2025-12-27 08:02:46 +08:00
7 changed files with 222 additions and 3 deletions

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24px" height="24px"><path d="M20,13.89A.77.77,0,0,0,19,13.73l-7,5.14v.22a.72.72,0,1,1,0,1.43v0a.74.74,0,0,0,.45-.15l7.41-5.47A.76.76,0,0,0,20,13.89Z" style="fill:#669df6"/><path d="M12,20.52a.72.72,0,0,1,0-1.43h0v-.22L5,13.73a.76.76,0,0,0-1,.16.74.74,0,0,0,.16,1l7.41,5.47a.73.73,0,0,0,.44.15v0Z" style="fill:#aecbfa"/><path d="M12,18.34a1.47,1.47,0,1,0,1.47,1.47A1.47,1.47,0,0,0,12,18.34Zm0,2.18a.72.72,0,1,1,.72-.71A.71.71,0,0,1,12,20.52Z" style="fill:#4285f4"/><path d="M6,6.11a.76.76,0,0,1-.75-.75V3.48a.76.76,0,1,1,1.51,0V5.36A.76.76,0,0,1,6,6.11Z" style="fill:#aecbfa"/><circle cx="5.98" cy="12" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="9.79" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="7.57" r="0.76" style="fill:#aecbfa"/><path d="M18,8.31a.76.76,0,0,1-.75-.76V5.67a.75.75,0,1,1,1.5,0V7.55A.75.75,0,0,1,18,8.31Z" style="fill:#4285f4"/><circle cx="18.02" cy="12.01" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="9.76" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="3.48" r="0.76" style="fill:#4285f4"/><path d="M12,15a.76.76,0,0,1-.75-.75V12.34a.76.76,0,0,1,1.51,0v1.89A.76.76,0,0,1,12,15Z" style="fill:#669df6"/><circle cx="12" cy="16.45" r="0.76" style="fill:#669df6"/><circle cx="12" cy="10.14" r="0.76" style="fill:#669df6"/><circle cx="12" cy="7.92" r="0.76" style="fill:#669df6"/><path d="M15,10.54a.76.76,0,0,1-.75-.75V7.91a.76.76,0,1,1,1.51,0V9.79A.76.76,0,0,1,15,10.54Z" style="fill:#4285f4"/><circle cx="15.01" cy="5.69" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="14.19" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="11.97" r="0.76" style="fill:#4285f4"/><circle cx="8.99" cy="14.19" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="7.92" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="5.69" r="0.76" style="fill:#aecbfa"/><path d="M9,12.73A.76.76,0,0,1,8.24,12V10.1a.75.75,0,1,1,1.5,0V12A.75.75,0,0,1,9,12.73Z" style="fill:#aecbfa"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -358,7 +358,7 @@
"models_excluded_hint": "This model is excluded by OAuth" "models_excluded_hint": "This model is excluded by OAuth"
}, },
"vertex_import": { "vertex_import": {
"title": "Vertex AI Credential Import", "title": "Vertex JSON Login",
"description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.", "description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.",
"location_label": "Region (optional)", "location_label": "Region (optional)",
"location_placeholder": "us-central1", "location_placeholder": "us-central1",

View File

@@ -358,7 +358,7 @@
"models_excluded_hint": "此模型已被 OAuth 排除" "models_excluded_hint": "此模型已被 OAuth 排除"
}, },
"vertex_import": { "vertex_import": {
"title": "Vertex AI 凭证导入", "title": "Vertex JSON 登录",
"description": "上传 Google 服务账号 JSON使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。", "description": "上传 Google 服务账号 JSON使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
"location_label": "目标区域 (可选)", "location_label": "目标区域 (可选)",
"location_placeholder": "us-central1", "location_placeholder": "us-central1",

View File

@@ -114,3 +114,25 @@
gap: $spacing-sm; gap: $spacing-sm;
margin-top: $spacing-sm; margin-top: $spacing-sm;
} }
.filePicker {
display: flex;
align-items: center;
gap: $spacing-sm;
flex-wrap: wrap;
}
.fileName {
flex: 1;
min-width: 220px;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
}
.fileNamePlaceholder {
color: var(--text-secondary);
}

View File

@@ -1,10 +1,11 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState, type ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { useNotificationStore, useThemeStore } from '@/stores'; import { useNotificationStore, useThemeStore } from '@/stores';
import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth'; import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth';
import { vertexApi, type VertexImportResponse } from '@/services/api/vertex';
import styles from './OAuthPage.module.scss'; import styles from './OAuthPage.module.scss';
import iconOpenaiLight from '@/assets/icons/openai-light.svg'; import iconOpenaiLight from '@/assets/icons/openai-light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg'; import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
@@ -13,6 +14,7 @@ import iconAntigravity from '@/assets/icons/antigravity.svg';
import iconGemini from '@/assets/icons/gemini.svg'; import iconGemini from '@/assets/icons/gemini.svg';
import iconQwen from '@/assets/icons/qwen.svg'; import iconQwen from '@/assets/icons/qwen.svg';
import iconIflow from '@/assets/icons/iflow.svg'; import iconIflow from '@/assets/icons/iflow.svg';
import iconVertex from '@/assets/icons/vertex.svg';
interface ProviderState { interface ProviderState {
url?: string; url?: string;
@@ -36,6 +38,22 @@ interface IFlowCookieState {
errorType?: 'error' | 'warning'; errorType?: 'error' | 'warning';
} }
interface VertexImportResult {
projectId?: string;
email?: string;
location?: string;
authFile?: string;
}
interface VertexImportState {
file?: File;
fileName: string;
location: string;
loading: boolean;
error?: string;
result?: VertexImportResult;
}
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [ const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconOpenaiLight, dark: iconOpenaiDark } }, { id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconOpenaiLight, dark: iconOpenaiDark } },
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude }, { id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
@@ -57,7 +75,13 @@ export function OAuthPage() {
const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>); const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>);
const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false }); const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false });
const [vertexState, setVertexState] = useState<VertexImportState>({
fileName: '',
location: '',
loading: false
});
const timers = useRef<Record<string, number>>({}); const timers = useRef<Record<string, number>>({});
const vertexFileInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -216,6 +240,64 @@ export function OAuthPage() {
} }
}; };
const handleVertexFilePick = () => {
vertexFileInputRef.current?.click();
};
const handleVertexFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.name.endsWith('.json')) {
showNotification(t('vertex_import.file_required'), 'warning');
event.target.value = '';
return;
}
setVertexState((prev) => ({
...prev,
file,
fileName: file.name,
error: undefined,
result: undefined
}));
event.target.value = '';
};
const handleVertexImport = async () => {
if (!vertexState.file) {
const message = t('vertex_import.file_required');
setVertexState((prev) => ({ ...prev, error: message }));
showNotification(message, 'warning');
return;
}
const location = vertexState.location.trim();
setVertexState((prev) => ({ ...prev, loading: true, error: undefined, result: undefined }));
try {
const res: VertexImportResponse = await vertexApi.importCredential(
vertexState.file,
location || undefined
);
const result: VertexImportResult = {
projectId: res.project_id,
email: res.email,
location: res.location,
authFile: res['auth-file'] ?? res.auth_file
};
setVertexState((prev) => ({ ...prev, loading: false, result }));
showNotification(t('vertex_import.success'), 'success');
} catch (err: any) {
const message = err?.message || '';
setVertexState((prev) => ({
...prev,
loading: false,
error: message || t('notification.upload_failed')
}));
const notification = message
? `${t('notification.upload_failed')}: ${message}`
: t('notification.upload_failed');
showNotification(notification, 'error');
}
};
return ( return (
<div className={styles.container}> <div className={styles.container}>
<h1 className={styles.pageTitle}>{t('nav.oauth', { defaultValue: 'OAuth' })}</h1> <h1 className={styles.pageTitle}>{t('nav.oauth', { defaultValue: 'OAuth' })}</h1>
@@ -328,6 +410,94 @@ export function OAuthPage() {
); );
})} })}
{/* Vertex JSON 登录 */}
<Card
title={
<span className={styles.cardTitle}>
<img src={iconVertex} alt="" className={styles.cardTitleIcon} />
{t('vertex_import.title')}
</span>
}
extra={
<Button onClick={handleVertexImport} loading={vertexState.loading}>
{t('vertex_import.import_button')}
</Button>
}
>
<div className="hint">{t('vertex_import.description')}</div>
<Input
label={t('vertex_import.location_label')}
hint={t('vertex_import.location_hint')}
value={vertexState.location}
onChange={(e) =>
setVertexState((prev) => ({
...prev,
location: e.target.value
}))
}
placeholder={t('vertex_import.location_placeholder')}
/>
<div className="form-group">
<label>{t('vertex_import.file_label')}</label>
<div className={styles.filePicker}>
<Button variant="secondary" size="sm" onClick={handleVertexFilePick}>
{t('vertex_import.choose_file')}
</Button>
<div
className={`${styles.fileName} ${
vertexState.fileName ? '' : styles.fileNamePlaceholder
}`.trim()}
>
{vertexState.fileName || t('vertex_import.file_placeholder')}
</div>
</div>
<div className="hint">{t('vertex_import.file_hint')}</div>
<input
ref={vertexFileInputRef}
type="file"
accept=".json,application/json"
style={{ display: 'none' }}
onChange={handleVertexFileChange}
/>
</div>
{vertexState.error && (
<div className="status-badge error" style={{ marginTop: 8 }}>
{vertexState.error}
</div>
)}
{vertexState.result && (
<div className="connection-box" style={{ marginTop: 12 }}>
<div className="label">{t('vertex_import.result_title')}</div>
<div className="key-value-list">
{vertexState.result.projectId && (
<div className="key-value-item">
<span className="key">{t('vertex_import.result_project')}</span>
<span className="value">{vertexState.result.projectId}</span>
</div>
)}
{vertexState.result.email && (
<div className="key-value-item">
<span className="key">{t('vertex_import.result_email')}</span>
<span className="value">{vertexState.result.email}</span>
</div>
)}
{vertexState.result.location && (
<div className="key-value-item">
<span className="key">{t('vertex_import.result_location')}</span>
<span className="value">{vertexState.result.location}</span>
</div>
)}
{vertexState.result.authFile && (
<div className="key-value-item">
<span className="key">{t('vertex_import.result_file')}</span>
<span className="value">{vertexState.result.authFile}</span>
</div>
)}
</div>
</div>
)}
</Card>
{/* iFlow Cookie 登录 */} {/* iFlow Cookie 登录 */}
<Card <Card
title={ title={

View File

@@ -11,3 +11,4 @@ export * from './logs';
export * from './version'; export * from './version';
export * from './models'; export * from './models';
export * from './transformers'; export * from './transformers';
export * from './vertex';

View File

@@ -0,0 +1,25 @@
/**
* Vertex credential import API
*/
import { apiClient } from './client';
export interface VertexImportResponse {
status: 'ok';
project_id?: string;
email?: string;
location?: string;
'auth-file'?: string;
auth_file?: string;
}
export const vertexApi = {
importCredential: (file: File, location?: string) => {
const formData = new FormData();
formData.append('file', file);
if (location) {
formData.append('location', location);
}
return apiClient.postForm<VertexImportResponse>('/vertex/import', formData);
}
};