mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
feat: add account oauth flow
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
+2
-2
@@ -13,8 +13,8 @@ ws_pool:
|
||||
max_per_account: 8
|
||||
fingerprint:
|
||||
originator: Codex Desktop
|
||||
app_version: '26.519.81530'
|
||||
build_number: '3178'
|
||||
app_version: '26.623.42026'
|
||||
build_number: '4514'
|
||||
platform: darwin
|
||||
arch: arm64
|
||||
chromium_version: '146'
|
||||
|
||||
@@ -23,7 +23,9 @@ use crate::{
|
||||
AdminEnvelope, AdminError, AdminResponse, CursorPageMeta, NumberedPageMeta, PageMeta,
|
||||
},
|
||||
admin::{
|
||||
accounts::service::{AdminAccountError, AdminAccountMetadata, AdminAccountMetadataUpdate},
|
||||
accounts::service::{
|
||||
AdminAccountError, AdminAccountMetadata, AdminAccountMetadataUpdate, OAuthExchangeInput,
|
||||
},
|
||||
monitoring::{billing, service::AdminUsageRecord},
|
||||
},
|
||||
http::middleware::request_id::RequestId,
|
||||
@@ -109,6 +111,15 @@ pub(crate) struct AccountTestRequest {
|
||||
model_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct AccountOAuthExchangeRequest {
|
||||
session_id: String,
|
||||
callback_url: Option<String>,
|
||||
code: Option<String>,
|
||||
state: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct AccountTestModelsData {
|
||||
@@ -482,6 +493,15 @@ struct AccountImportData {
|
||||
source_format: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AccountOAuthAuthorizeData {
|
||||
session_id: String,
|
||||
auth_url: String,
|
||||
expires_at: String,
|
||||
expires_at_display: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct HealthCheckData {
|
||||
@@ -850,6 +870,66 @@ pub(crate) async fn import_accounts(
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/admin/accounts/oauth/authorize`
|
||||
pub(crate) async fn oauth_authorize_account(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
match state.services.admin_accounts.oauth_authorize().await {
|
||||
Ok(result) => Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(
|
||||
AccountOAuthAuthorizeData {
|
||||
session_id: result.session_id,
|
||||
auth_url: result.auth_url,
|
||||
expires_at: china_rfc3339(&result.expires_at),
|
||||
expires_at_display: china_datetime(&result.expires_at),
|
||||
},
|
||||
request_id,
|
||||
),
|
||||
)),
|
||||
Err(error) => Err(account_error(error, request_id)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/admin/accounts/oauth/exchange`
|
||||
pub(crate) async fn oauth_exchange_account(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<AccountOAuthExchangeRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
match state
|
||||
.services
|
||||
.admin_accounts
|
||||
.oauth_exchange(OAuthExchangeInput {
|
||||
session_id: payload.session_id,
|
||||
callback_url: payload.callback_url,
|
||||
code: payload.code,
|
||||
state: payload.state,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(
|
||||
AccountImportData {
|
||||
imported: result.imported,
|
||||
skipped: result.skipped,
|
||||
source_format: result.source_format.to_string(),
|
||||
},
|
||||
request_id,
|
||||
),
|
||||
)),
|
||||
Err(error) => Err(account_error(error, request_id)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/admin/accounts/test?id=...`
|
||||
pub(crate) async fn test_account_connection(
|
||||
State(state): State<AppState>,
|
||||
@@ -1749,12 +1829,21 @@ fn account_error(error: AdminAccountError, request_id: String) -> AdminError {
|
||||
| AdminAccountError::TokenRequired
|
||||
| AdminAccountError::InvalidToken(_)
|
||||
| AdminAccountError::RefreshTokenExchange(_)
|
||||
| AdminAccountError::OAuthSessionInvalid
|
||||
| AdminAccountError::OAuthCallbackInvalid
|
||||
| AdminAccountError::OAuthStateMismatch
|
||||
| AdminAccountError::NoValidCookies => AdminError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
40001,
|
||||
error.to_string(),
|
||||
request_id,
|
||||
),
|
||||
AdminAccountError::OAuthCodeExchange(_) => AdminError::new(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
50201,
|
||||
error.to_string(),
|
||||
request_id,
|
||||
),
|
||||
AdminAccountError::NotFound => account_not_found(request_id),
|
||||
AdminAccountError::Inactive(_) => {
|
||||
AdminError::new(StatusCode::CONFLICT, 40901, error.to_string(), request_id)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
mod cookies;
|
||||
mod importing;
|
||||
mod lifecycle;
|
||||
pub(crate) mod oauth;
|
||||
mod quota;
|
||||
mod testing;
|
||||
mod types;
|
||||
@@ -22,7 +23,8 @@ use crate::{
|
||||
|
||||
pub use types::{
|
||||
AdminAccountError, AdminAccountMetadata, AdminAccountMetadataUpdate, BatchDeleteAccounts,
|
||||
BatchUpdateAccountStatus, ImportedAccounts, UpdatedAccountStatus,
|
||||
BatchUpdateAccountStatus, ImportedAccounts, OAuthAuthorizeResult, OAuthExchangeInput,
|
||||
UpdatedAccountStatus,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -33,6 +35,7 @@ pub struct AdminAccountService {
|
||||
pub(crate) codex: StdArc<CodexBackendClient>,
|
||||
pub(crate) account_pool: StdArc<RuntimeAccountPoolService>,
|
||||
pub(crate) token_refresher: StdArc<dyn TokenRefresher>,
|
||||
pub(crate) oauth: oauth::AccountOAuthService,
|
||||
pub(crate) refresh_margin_seconds: u64,
|
||||
pub(crate) installation_id: Option<String>,
|
||||
}
|
||||
@@ -49,6 +52,7 @@ impl AdminAccountService {
|
||||
codex: StdArc<CodexBackendClient>,
|
||||
account_pool: StdArc<RuntimeAccountPoolService>,
|
||||
token_refresher: StdArc<dyn TokenRefresher>,
|
||||
oauth: oauth::AccountOAuthService,
|
||||
refresh_margin_seconds: u64,
|
||||
installation_id: Option<String>,
|
||||
) -> Self {
|
||||
@@ -59,6 +63,7 @@ impl AdminAccountService {
|
||||
codex,
|
||||
account_pool,
|
||||
token_refresher,
|
||||
oauth,
|
||||
refresh_margin_seconds,
|
||||
installation_id,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use rand::Rng;
|
||||
use reqwest::{Client, StatusCode, Url};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::{
|
||||
types::{AdminAccountError, OAuthAuthorizeResult, OAuthExchangeInput},
|
||||
AdminAccountService,
|
||||
};
|
||||
|
||||
const OPENAI_OAUTH_AUTHORIZE_ENDPOINT: &str = "https://auth.openai.com/oauth/authorize";
|
||||
const OPENAI_OAUTH_REDIRECT_URI: &str = "http://localhost:1455/auth/callback";
|
||||
const OPENAI_OAUTH_SCOPE: &str = "openid profile email offline_access";
|
||||
const OAUTH_SESSION_TTL_MINUTES: i64 = 30;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AccountOAuthService {
|
||||
client: Client,
|
||||
client_id: String,
|
||||
token_endpoint: String,
|
||||
sessions: Arc<Mutex<HashMap<String, AccountOAuthSession>>>,
|
||||
}
|
||||
|
||||
impl AccountOAuthService {
|
||||
pub fn new(client: Client, client_id: String, token_endpoint: String) -> Self {
|
||||
Self {
|
||||
client,
|
||||
client_id,
|
||||
token_endpoint,
|
||||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn authorize(&self) -> Result<OAuthAuthorizeResult, AdminAccountError> {
|
||||
let now = Utc::now();
|
||||
let expires_at = now + Duration::minutes(OAUTH_SESSION_TTL_MINUTES);
|
||||
let session_id = random_url_token(32);
|
||||
let state = random_url_token(32);
|
||||
let code_verifier = random_hex_token(64);
|
||||
let code_challenge = code_challenge(&code_verifier);
|
||||
let session = AccountOAuthSession {
|
||||
state: state.clone(),
|
||||
code_verifier,
|
||||
expires_at,
|
||||
};
|
||||
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
sessions.retain(|_, session| session.expires_at > now);
|
||||
sessions.insert(session_id.clone(), session);
|
||||
drop(sessions);
|
||||
|
||||
let mut auth_url =
|
||||
Url::parse(OPENAI_OAUTH_AUTHORIZE_ENDPOINT).map_err(|_| AdminAccountError::Import)?;
|
||||
auth_url
|
||||
.query_pairs_mut()
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("client_id", &self.client_id)
|
||||
.append_pair("redirect_uri", OPENAI_OAUTH_REDIRECT_URI)
|
||||
.append_pair("scope", OPENAI_OAUTH_SCOPE)
|
||||
.append_pair("state", &state)
|
||||
.append_pair("code_challenge", &code_challenge)
|
||||
.append_pair("code_challenge_method", "S256")
|
||||
.append_pair("id_token_add_organizations", "true")
|
||||
.append_pair("codex_cli_simplified_flow", "true");
|
||||
|
||||
Ok(OAuthAuthorizeResult {
|
||||
session_id,
|
||||
auth_url: auth_url.to_string(),
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
|
||||
async fn exchange(
|
||||
&self,
|
||||
input: OAuthExchangeInput,
|
||||
) -> Result<AccountOAuthTokens, AdminAccountError> {
|
||||
let (code, state) = oauth_callback_parts(&input)?;
|
||||
let session = self.session(&input.session_id).await?;
|
||||
if session.state != state {
|
||||
return Err(AdminAccountError::OAuthStateMismatch);
|
||||
}
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&self.token_endpoint)
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("client_id", self.client_id.as_str()),
|
||||
("code", code.as_str()),
|
||||
("redirect_uri", OPENAI_OAUTH_REDIRECT_URI),
|
||||
("code_verifier", session.code_verifier.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| AdminAccountError::OAuthCodeExchange(error.to_string()))?;
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| AdminAccountError::OAuthCodeExchange(error.to_string()))?;
|
||||
if !status.is_success() {
|
||||
return Err(AdminAccountError::OAuthCodeExchange(exchange_error_text(
|
||||
status, &body,
|
||||
)));
|
||||
}
|
||||
|
||||
let tokens = serde_json::from_str::<AccountOAuthTokens>(&body)
|
||||
.map_err(|_| AdminAccountError::OAuthCodeExchange("invalid token response".into()))?;
|
||||
if tokens.access_token.trim().is_empty() {
|
||||
return Err(AdminAccountError::OAuthCodeExchange(
|
||||
"empty access token".into(),
|
||||
));
|
||||
}
|
||||
|
||||
self.remove_session(&input.session_id).await;
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
async fn session(&self, session_id: &str) -> Result<AccountOAuthSession, AdminAccountError> {
|
||||
let now = Utc::now();
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
sessions.retain(|_, session| session.expires_at > now);
|
||||
sessions
|
||||
.get(session_id)
|
||||
.cloned()
|
||||
.ok_or(AdminAccountError::OAuthSessionInvalid)
|
||||
}
|
||||
|
||||
async fn remove_session(&self, session_id: &str) {
|
||||
self.sessions.lock().await.remove(session_id);
|
||||
}
|
||||
}
|
||||
|
||||
impl AdminAccountService {
|
||||
pub async fn oauth_authorize(&self) -> Result<OAuthAuthorizeResult, AdminAccountError> {
|
||||
self.oauth.authorize().await
|
||||
}
|
||||
|
||||
pub async fn oauth_exchange(
|
||||
&self,
|
||||
input: OAuthExchangeInput,
|
||||
) -> Result<super::types::ImportedAccounts, AdminAccountError> {
|
||||
let tokens = self.oauth.exchange(input).await?;
|
||||
self.import(serde_json::json!({
|
||||
"sourceFormat": "native",
|
||||
"accounts": [{
|
||||
"token": tokens.access_token,
|
||||
"refreshToken": tokens.refresh_token,
|
||||
}]
|
||||
}))
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AccountOAuthSession {
|
||||
state: String,
|
||||
code_verifier: String,
|
||||
expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AccountOAuthTokens {
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
}
|
||||
|
||||
fn oauth_callback_parts(input: &OAuthExchangeInput) -> Result<(String, String), AdminAccountError> {
|
||||
if let Some(callback_url) = input.callback_url.as_deref() {
|
||||
let (code, state) = callback_url_parts(callback_url)?;
|
||||
return Ok((code, state));
|
||||
}
|
||||
|
||||
let code = normalized(input.code.as_deref()).ok_or(AdminAccountError::OAuthCallbackInvalid)?;
|
||||
let state =
|
||||
normalized(input.state.as_deref()).ok_or(AdminAccountError::OAuthCallbackInvalid)?;
|
||||
Ok((code, state))
|
||||
}
|
||||
|
||||
fn callback_url_parts(callback_url: &str) -> Result<(String, String), AdminAccountError> {
|
||||
let url =
|
||||
Url::parse(callback_url.trim()).map_err(|_| AdminAccountError::OAuthCallbackInvalid)?;
|
||||
let mut code = None;
|
||||
let mut state = None;
|
||||
for (key, value) in url.query_pairs() {
|
||||
match key.as_ref() {
|
||||
"code" => code = normalized(Some(value.as_ref())),
|
||||
"state" => state = normalized(Some(value.as_ref())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
code.zip(state)
|
||||
.ok_or(AdminAccountError::OAuthCallbackInvalid)
|
||||
}
|
||||
|
||||
fn normalized(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn code_challenge(verifier: &str) -> String {
|
||||
URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
|
||||
}
|
||||
|
||||
fn random_url_token(size: usize) -> String {
|
||||
let mut bytes = vec![0u8; size];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
fn random_hex_token(size: usize) -> String {
|
||||
let mut bytes = vec![0u8; size];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
fn exchange_error_text(status: StatusCode, body: &str) -> String {
|
||||
let body = body.trim();
|
||||
if body.is_empty() {
|
||||
return format!("upstream returned {status}");
|
||||
}
|
||||
format!("upstream returned {status}: {body}")
|
||||
}
|
||||
@@ -110,6 +110,14 @@ pub enum AdminAccountError {
|
||||
InvalidToken(&'static str),
|
||||
#[error("token refresh exchange failed: {0}")]
|
||||
RefreshTokenExchange(crate::upstream::accounts::token_refresh::RefreshFailure),
|
||||
#[error("oauth session is invalid or expired")]
|
||||
OAuthSessionInvalid,
|
||||
#[error("oauth callback is missing code or state")]
|
||||
OAuthCallbackInvalid,
|
||||
#[error("oauth state does not match")]
|
||||
OAuthStateMismatch,
|
||||
#[error("oauth code exchange failed: {0}")]
|
||||
OAuthCodeExchange(String),
|
||||
#[error("no valid cookies provided")]
|
||||
NoValidCookies,
|
||||
#[error("account is {0}")]
|
||||
@@ -162,6 +170,21 @@ pub struct ImportedAccounts {
|
||||
pub source_format: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OAuthAuthorizeResult {
|
||||
pub session_id: String,
|
||||
pub auth_url: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OAuthExchangeInput {
|
||||
pub session_id: String,
|
||||
pub callback_url: Option<String>,
|
||||
pub code: Option<String>,
|
||||
pub state: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct ManualCreateTokens {
|
||||
pub(super) access_token: String,
|
||||
|
||||
+10
-2
@@ -11,8 +11,8 @@ use super::{
|
||||
accounts::routes::{
|
||||
account_quota, account_test_models, accounts, batch_delete_accounts, create_account,
|
||||
export_accounts, get_account_cookies, health_check_accounts, import_accounts,
|
||||
quota_warnings, refresh_account, reset_account_usage, set_account_cookies,
|
||||
test_account_connection, update_account,
|
||||
oauth_authorize_account, oauth_exchange_account, quota_warnings, refresh_account,
|
||||
reset_account_usage, set_account_cookies, test_account_connection, update_account,
|
||||
},
|
||||
auth::session::{login, logout, session_status},
|
||||
keys::routes::{
|
||||
@@ -42,6 +42,14 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/api/admin/accounts", get(accounts).post(create_account))
|
||||
.route("/api/admin/accounts/export", get(export_accounts))
|
||||
.route("/api/admin/accounts/import", post(import_accounts))
|
||||
.route(
|
||||
"/api/admin/accounts/oauth/authorize",
|
||||
post(oauth_authorize_account),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/accounts/oauth/exchange",
|
||||
post(oauth_exchange_account),
|
||||
)
|
||||
.route("/api/admin/accounts/quota-warnings", get(quota_warnings))
|
||||
.route(
|
||||
"/api/admin/accounts/health-check",
|
||||
|
||||
@@ -166,6 +166,11 @@ impl Services {
|
||||
codex.clone(),
|
||||
account_pool.clone(),
|
||||
token_client,
|
||||
crate::admin::accounts::service::oauth::AccountOAuthService::new(
|
||||
reqwest::Client::new(),
|
||||
config.auth.oauth_client_id.clone(),
|
||||
config.auth.oauth_token_endpoint.clone(),
|
||||
),
|
||||
config.auth.refresh_margin_seconds,
|
||||
installation_id.clone(),
|
||||
));
|
||||
|
||||
@@ -34,6 +34,7 @@ use tower::util::ServiceExt;
|
||||
mod import_export;
|
||||
mod lifecycle;
|
||||
mod list;
|
||||
mod oauth;
|
||||
mod quota;
|
||||
mod testing;
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
use super::*;
|
||||
use wiremock::{
|
||||
matchers::{body_string_contains, method, path},
|
||||
Mock, MockServer, ResponseTemplate,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_account_oauth_authorize_should_return_pkce_auth_url() {
|
||||
let (app, _state, _pool, _dir) =
|
||||
admin_accounts_test_app("admin-account-oauth-authorize.sqlite", 94).await;
|
||||
|
||||
let response = post_oauth_authorize(&app, "req_oauth_authorize").await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response_json(response).await;
|
||||
let auth_url = body["data"]["authUrl"].as_str().unwrap();
|
||||
let url = reqwest::Url::parse(auth_url).unwrap();
|
||||
let params: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect();
|
||||
assert_eq!(
|
||||
url.as_str().split('?').next().unwrap(),
|
||||
"https://auth.openai.com/oauth/authorize"
|
||||
);
|
||||
assert_eq!(
|
||||
params.get("response_type").map(String::as_str),
|
||||
Some("code")
|
||||
);
|
||||
assert_eq!(
|
||||
params.get("client_id").map(String::as_str),
|
||||
Some("app_EMoamEEZ73f0CkXaXp7hrann")
|
||||
);
|
||||
assert_eq!(
|
||||
params.get("redirect_uri").map(String::as_str),
|
||||
Some("http://localhost:1455/auth/callback")
|
||||
);
|
||||
assert_eq!(
|
||||
params.get("scope").map(String::as_str),
|
||||
Some("openid profile email offline_access")
|
||||
);
|
||||
assert_eq!(
|
||||
params.get("code_challenge_method").map(String::as_str),
|
||||
Some("S256")
|
||||
);
|
||||
assert!(params.get("state").is_some_and(|value| !value.is_empty()));
|
||||
assert!(params
|
||||
.get("code_challenge")
|
||||
.is_some_and(|value| !value.is_empty()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_account_oauth_exchange_should_reject_callback_without_state() {
|
||||
let server = MockServer::start().await;
|
||||
let (app, _state, _pool, _dir) = admin_accounts_test_app_with_oauth_token_endpoint(
|
||||
"admin-account-oauth-missing-state.sqlite",
|
||||
95,
|
||||
format!("{}/oauth/token", server.uri()),
|
||||
)
|
||||
.await;
|
||||
let authorize =
|
||||
response_json(post_oauth_authorize(&app, "req_oauth_missing_state").await).await;
|
||||
let session_id = authorize["data"]["sessionId"].as_str().unwrap();
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/accounts/oauth/exchange")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_oauth_missing_state_exchange")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"sessionId": session_id,
|
||||
"callbackUrl": "http://localhost:1455/auth/callback?code=oauth-code"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert!(server.received_requests().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_account_oauth_exchange_should_import_account_tokens() {
|
||||
let server = MockServer::start().await;
|
||||
let access_token = test_jwt(
|
||||
"chatgpt-oauth",
|
||||
Some("user-oauth"),
|
||||
Some("oauth@example.com"),
|
||||
Some("plus"),
|
||||
);
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.and(body_string_contains("grant_type=authorization_code"))
|
||||
.and(body_string_contains(
|
||||
"client_id=app_EMoamEEZ73f0CkXaXp7hrann",
|
||||
))
|
||||
.and(body_string_contains("code=oauth-code"))
|
||||
.and(body_string_contains(
|
||||
"redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback",
|
||||
))
|
||||
.and(body_string_contains("code_verifier="))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"access_token": access_token,
|
||||
"refresh_token": "refresh-oauth"
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"account_id": "chatgpt-oauth",
|
||||
"user_id": "user-oauth",
|
||||
"email": "oauth@example.com",
|
||||
"plan_type": "plus",
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"primary_window": {
|
||||
"used_percent": 1,
|
||||
"limit_window_seconds": 18000
|
||||
}
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let (app, _state, pool, _dir) =
|
||||
admin_accounts_test_app_with_api_base_url_and_oauth_token_endpoint(
|
||||
"admin-account-oauth-exchange.sqlite",
|
||||
96,
|
||||
server.uri(),
|
||||
format!("{}/oauth/token", server.uri()),
|
||||
)
|
||||
.await;
|
||||
let authorize = response_json(post_oauth_authorize(&app, "req_oauth_exchange").await).await;
|
||||
let session_id = authorize["data"]["sessionId"].as_str().unwrap();
|
||||
let auth_url = reqwest::Url::parse(authorize["data"]["authUrl"].as_str().unwrap()).unwrap();
|
||||
let state = auth_url
|
||||
.query_pairs()
|
||||
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
|
||||
.unwrap();
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/accounts/oauth/exchange")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_oauth_exchange_done")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"sessionId": session_id,
|
||||
"callbackUrl": format!(
|
||||
"http://localhost:1455/auth/callback?code=oauth-code&state={state}"
|
||||
)
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response_json(response).await;
|
||||
assert_eq!(body["data"]["imported"], 1);
|
||||
let stored: (String, String, String, String) = sqlx::query_as(
|
||||
"select email, chatgpt_account_id, access_token, refresh_token from accounts where chatgpt_account_id = ?",
|
||||
)
|
||||
.bind("chatgpt-oauth")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stored.0, "oauth@example.com");
|
||||
assert_eq!(stored.1, "chatgpt-oauth");
|
||||
assert_eq!(stored.2, access_token);
|
||||
assert_eq!(stored.3, "refresh-oauth");
|
||||
}
|
||||
|
||||
async fn post_oauth_authorize(app: &axum::Router, request_id: &str) -> axum::response::Response {
|
||||
app.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/accounts/oauth/authorize")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", request_id)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
@@ -24,6 +24,22 @@ export function importAccounts(data: any) {
|
||||
})
|
||||
}
|
||||
|
||||
export function authorizeAccountOAuth(data?: any) {
|
||||
return request({
|
||||
url: '/api/admin/accounts/oauth/authorize',
|
||||
method: 'POST',
|
||||
data: data || {},
|
||||
})
|
||||
}
|
||||
|
||||
export function exchangeAccountOAuth(data: any) {
|
||||
return request({
|
||||
url: '/api/admin/accounts/oauth/exchange',
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteAccounts(data: any) {
|
||||
return request({
|
||||
url: '/api/admin/accounts/delete',
|
||||
|
||||
@@ -32,7 +32,7 @@ const slots = useSlots()
|
||||
|
||||
const variantClasses = {
|
||||
default:
|
||||
'bg-(--cp-bg-subtle) text-(--cp-text-primary) hover:bg-(--cp-default-bg-hover) active:bg-(--cp-default-bg-active)',
|
||||
'bg-(--cp-bg-muted) text-(--cp-text-primary) shadow-(--cp-shadow-control) hover:bg-(--cp-default-bg-active) active:bg-(--cp-default-bg-active)',
|
||||
primary:
|
||||
'bg-(--cp-info) text-(--cp-info-on) hover:bg-(--cp-info-hover) active:bg-(--cp-info-pressed)',
|
||||
success:
|
||||
@@ -94,7 +94,7 @@ const labelText = computed(() => {
|
||||
})
|
||||
|
||||
const labelClasses = computed(() => [
|
||||
'inline-flex min-w-0 items-center justify-center',
|
||||
'inline-flex min-w-0 items-center justify-center gap-2 [&>svg]:shrink-0',
|
||||
labelText.value.length === 2 ? 'tracking-[0.12em]' : undefined,
|
||||
])
|
||||
</script>
|
||||
|
||||
@@ -323,7 +323,7 @@ function paginationPageClass(page: number) {
|
||||
@mouseenter="horizontalHovering = true"
|
||||
@mouseleave="horizontalHovering = false"
|
||||
>
|
||||
<div class="relative flex min-h-0 max-w-full flex-1 overflow-hidden pb-3">
|
||||
<div v-loading="loading" class="relative flex min-h-0 max-w-full flex-1 overflow-hidden pb-3">
|
||||
<div class="flex min-h-0 max-w-full flex-1 flex-col overflow-hidden">
|
||||
<div ref="headerWrap" class="max-w-full overflow-hidden">
|
||||
<table
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { Upload } from '@lucide/vue'
|
||||
import { Copy, KeyRound, Upload } from '@lucide/vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseModal from '@/components/base/BaseModal.vue'
|
||||
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
|
||||
import BaseSegmented from '@/components/base/BaseSegmented.vue'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
import { copyText } from '@/utils/clipboard'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
saving?: boolean
|
||||
oauthLoading?: boolean
|
||||
}>(),
|
||||
{
|
||||
saving: false,
|
||||
oauthLoading: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -20,14 +25,16 @@ const form = defineModel<any>('form', { required: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: []
|
||||
generateOauth: []
|
||||
}>()
|
||||
|
||||
const fileInput = useTemplateRef<HTMLInputElement>('fileInput')
|
||||
const fileError = ref('')
|
||||
|
||||
const modeOptions = [
|
||||
{ label: 'OAuth 授权', value: 'oauth' },
|
||||
{ label: 'RT 导入', value: 'rt' },
|
||||
{ label: '原生 JSON', value: 'native' },
|
||||
{ label: 'CPR', value: 'native' },
|
||||
{ label: 'Sub2API', value: 'sub2api' },
|
||||
]
|
||||
|
||||
@@ -54,8 +61,22 @@ const importText = computed({
|
||||
},
|
||||
})
|
||||
|
||||
const oauthCallback = computed({
|
||||
get: () => form.value.oauthCallback,
|
||||
set: (value: string) => {
|
||||
form.value = { ...form.value, oauthCallback: value }
|
||||
},
|
||||
})
|
||||
|
||||
const oauthAuthUrl = computed(() => form.value.oauthAuthUrl || '')
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (props.saving) return false
|
||||
if (props.saving || props.oauthLoading) return false
|
||||
if (mode.value === 'oauth') {
|
||||
return Boolean(
|
||||
form.value.oauthSessionId && oauthAuthUrl.value && oauthCallback.value.trim().length > 0,
|
||||
)
|
||||
}
|
||||
if (mode.value === 'rt') return refreshToken.value.trim().length > 0
|
||||
return importText.value.trim().length > 0
|
||||
})
|
||||
@@ -64,10 +85,13 @@ const description = computed(() => {
|
||||
if (mode.value === 'rt') {
|
||||
return '一行一个 Refresh Token,导入时会自动换取访问令牌并补全账号信息。'
|
||||
}
|
||||
if (mode.value === 'oauth') {
|
||||
return '复制 OpenAI 授权链接,完成后粘贴回调地址,系统会自动写入或更新账号。'
|
||||
}
|
||||
if (mode.value === 'sub2api') {
|
||||
return '导入 Sub2API 导出的账号 JSON,已存在账号会更新。'
|
||||
}
|
||||
return '导入 Codex Proxy RS 原生账号 JSON,已存在账号会更新。'
|
||||
return '导入 Codex Proxy RS 账号 JSON,已存在账号会更新。'
|
||||
})
|
||||
|
||||
async function handleFileChange(event: Event) {
|
||||
@@ -84,12 +108,23 @@ async function handleFileChange(event: Event) {
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function copyOAuthAuthUrl() {
|
||||
if (!oauthAuthUrl.value) return
|
||||
const copied = await copyText(oauthAuthUrl.value)
|
||||
if (copied) {
|
||||
toast.success('授权链接已复制')
|
||||
return
|
||||
}
|
||||
|
||||
toast.error('复制失败')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
v-model="open"
|
||||
title="导入账号"
|
||||
title="添加账号"
|
||||
:description="description"
|
||||
variant="info"
|
||||
width="620px"
|
||||
@@ -110,13 +145,84 @@ async function handleFileChange(event: Event) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="mode === 'oauth'" class="flex flex-col gap-4">
|
||||
<div class="rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-4 py-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-(--cp-icon-button-radius) bg-(--cp-bg-surface) text-(--cp-info)"
|
||||
>
|
||||
<KeyRound class="size-4" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="m-0 text-[13px] font-[720] text-(--cp-text-primary)">
|
||||
使用 OpenAI OAuth 完成账号接入
|
||||
</p>
|
||||
<p class="m-0 mt-1 text-[12px] leading-[1.55] font-medium text-(--cp-text-secondary)">
|
||||
复制授权链接到浏览器打开,完成后把回调地址粘贴到下方即可导入。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<BaseButton
|
||||
variant="default"
|
||||
:loading="oauthLoading"
|
||||
:disabled="saving"
|
||||
@click="emit('generateOauth')"
|
||||
>
|
||||
生成授权链接
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div v-if="oauthAuthUrl" class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<label class="block text-[13px] font-medium text-(--cp-text-secondary)">
|
||||
授权链接
|
||||
</label>
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="default"
|
||||
size="sm"
|
||||
title="复制链接"
|
||||
label="复制链接"
|
||||
:disabled="saving || oauthLoading"
|
||||
@click="copyOAuthAuthUrl"
|
||||
>
|
||||
<Copy class="size-3.5" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
<BaseScrollbar
|
||||
max-height="92px"
|
||||
view-class="rounded-(--cp-input-radius-base) bg-(--cp-input-current-bg,var(--cp-input-context-bg)) px-3.5 py-3 shadow-(--cp-shadow-input)"
|
||||
>
|
||||
<pre
|
||||
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[12px] leading-[1.6] font-[650] text-(--cp-text-secondary)"
|
||||
>{{ oauthAuthUrl }}</pre
|
||||
>
|
||||
</BaseScrollbar>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="block text-[13px] font-medium text-(--cp-text-secondary)">
|
||||
回调地址 <span class="text-(--cp-danger)">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="oauthCallback"
|
||||
class="h-28 w-full resize-none rounded-(--cp-input-radius-base) border-0 bg-(--cp-input-current-bg,var(--cp-input-context-bg)) px-3.5 py-3 font-mono text-[12px] leading-[1.55] font-[650] text-(--cp-text-primary) shadow-(--cp-shadow-input) outline-none transition-[background-color,box-shadow] duration-160 placeholder:text-(--cp-text-muted) hover:bg-(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover)) hover:shadow-(--cp-shadow-input-hover) focus:bg-(--cp-input-soft-bg-focus) focus:shadow-(--cp-shadow-input-focus) disabled:cursor-not-allowed disabled:bg-(--cp-disabled-bg) disabled:text-(--cp-disabled-text) disabled:shadow-none"
|
||||
placeholder="http://localhost:1455/auth/callback?code=...&state=..."
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<label class="block text-[13px] font-medium text-(--cp-text-secondary)">
|
||||
JSON 内容 <span class="text-(--cp-danger)">*</span>
|
||||
</label>
|
||||
<BaseButton variant="default" size="sm" :disabled="saving" @click="fileInput?.click()">
|
||||
<Upload class="size-4" />
|
||||
<Upload class="size-3.5" />
|
||||
上传文件
|
||||
</BaseButton>
|
||||
<input
|
||||
@@ -147,7 +253,7 @@ async function handleFileChange(event: Event) {
|
||||
:disabled="!canSubmit"
|
||||
@click="emit('create')"
|
||||
>
|
||||
导入
|
||||
{{ mode === 'oauth' ? '完成授权导入' : '导入' }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
</BaseModal>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { computed, onMounted, ref, type Ref } from 'vue'
|
||||
|
||||
import {
|
||||
authorizeAccountOAuth,
|
||||
deleteAccounts,
|
||||
exchangeAccountOAuth,
|
||||
getAccountQuota,
|
||||
getAccounts,
|
||||
importAccounts,
|
||||
@@ -36,15 +38,18 @@ export function useAccountMutations(options: {
|
||||
const refreshingQuotaAccountIds = ref<Set<string>>(new Set())
|
||||
const updatingStatusAccountIds = ref<Set<string>>(new Set())
|
||||
const deletingAccount = ref(false)
|
||||
const loaded = ref(false)
|
||||
const creatingAccount = ref(false)
|
||||
const authorizingOAuth = ref(false)
|
||||
const savingAccount = ref(false)
|
||||
const batchDeleting = ref(false)
|
||||
|
||||
const createForm = ref({
|
||||
mode: 'rt',
|
||||
mode: 'oauth',
|
||||
refreshToken: '',
|
||||
importText: '',
|
||||
oauthSessionId: '',
|
||||
oauthAuthUrl: '',
|
||||
oauthCallback: '',
|
||||
})
|
||||
|
||||
const editForm = ref({
|
||||
@@ -56,8 +61,6 @@ export function useAccountMutations(options: {
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
const initialLoading = computed(() => loading.value && !loaded.value)
|
||||
|
||||
const showEditModal = computed({
|
||||
get: () => editingAccount.value !== null,
|
||||
set: (value: boolean) => {
|
||||
@@ -100,11 +103,14 @@ export function useAccountMutations(options: {
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (createForm.value.mode === 'oauth') {
|
||||
await handleExchangeOAuth()
|
||||
return
|
||||
}
|
||||
if (creatingAccount.value) return
|
||||
|
||||
try {
|
||||
@@ -114,7 +120,7 @@ export function useAccountMutations(options: {
|
||||
creatingAccount.value = true
|
||||
const result = await importAccounts(payload)
|
||||
showCreateModal.value = false
|
||||
createForm.value = { mode: 'rt', refreshToken: '', importText: '' }
|
||||
resetCreateForm()
|
||||
await loadAccounts()
|
||||
toast.success(importSuccessText(result))
|
||||
} catch (error) {
|
||||
@@ -125,6 +131,14 @@ export function useAccountMutations(options: {
|
||||
}
|
||||
|
||||
function accountImportPayload() {
|
||||
if (createForm.value.mode === 'oauth') {
|
||||
if (!createForm.value.oauthSessionId || !createForm.value.oauthCallback.trim()) return null
|
||||
return {
|
||||
sessionId: createForm.value.oauthSessionId,
|
||||
callbackUrl: createForm.value.oauthCallback.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
if (createForm.value.mode === 'rt') {
|
||||
const accounts = createForm.value.refreshToken
|
||||
.split(/\r?\n/)
|
||||
@@ -155,6 +169,58 @@ export function useAccountMutations(options: {
|
||||
return { sourceFormat, accounts: [parsed] }
|
||||
}
|
||||
|
||||
async function handleAuthorizeOAuth() {
|
||||
if (authorizingOAuth.value) return
|
||||
|
||||
try {
|
||||
authorizingOAuth.value = true
|
||||
const result = await authorizeAccountOAuth()
|
||||
createForm.value = {
|
||||
...createForm.value,
|
||||
mode: 'oauth',
|
||||
oauthSessionId: result.sessionId,
|
||||
oauthAuthUrl: result.authUrl,
|
||||
oauthCallback: '',
|
||||
}
|
||||
toast.success('授权链接已生成')
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || '授权链接生成失败')
|
||||
} finally {
|
||||
authorizingOAuth.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExchangeOAuth() {
|
||||
if (creatingAccount.value) return
|
||||
|
||||
try {
|
||||
const payload = accountImportPayload()
|
||||
if (!payload) return
|
||||
|
||||
creatingAccount.value = true
|
||||
const result = await exchangeAccountOAuth(payload)
|
||||
showCreateModal.value = false
|
||||
resetCreateForm()
|
||||
await loadAccounts()
|
||||
toast.success(importSuccessText(result))
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'OAuth 授权导入失败')
|
||||
} finally {
|
||||
creatingAccount.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetCreateForm() {
|
||||
createForm.value = {
|
||||
mode: 'oauth',
|
||||
refreshToken: '',
|
||||
importText: '',
|
||||
oauthSessionId: '',
|
||||
oauthAuthUrl: '',
|
||||
oauthCallback: '',
|
||||
}
|
||||
}
|
||||
|
||||
function importSuccessText(result: any) {
|
||||
const imported = result?.imported ?? 0
|
||||
const skipped = result?.skipped ?? 0
|
||||
@@ -325,14 +391,16 @@ export function useAccountMutations(options: {
|
||||
updatingStatusAccountIds,
|
||||
deletingAccount,
|
||||
creatingAccount,
|
||||
authorizingOAuth,
|
||||
savingAccount,
|
||||
batchDeleting,
|
||||
createForm,
|
||||
editForm,
|
||||
editStatusModel,
|
||||
initialLoading,
|
||||
loadAccounts,
|
||||
handleCreate,
|
||||
handleAuthorizeOAuth,
|
||||
handleExchangeOAuth,
|
||||
openEditAccount,
|
||||
handleSaveAccount,
|
||||
requestDeleteAccount,
|
||||
|
||||
@@ -71,14 +71,15 @@ const {
|
||||
updatingStatusAccountIds,
|
||||
deletingAccount,
|
||||
creatingAccount,
|
||||
authorizingOAuth,
|
||||
savingAccount,
|
||||
batchDeleting,
|
||||
createForm,
|
||||
editForm,
|
||||
editStatusModel,
|
||||
initialLoading,
|
||||
loadAccounts,
|
||||
handleCreate,
|
||||
handleAuthorizeOAuth,
|
||||
openEditAccount,
|
||||
handleSaveAccount,
|
||||
requestDeleteAccount,
|
||||
@@ -144,7 +145,6 @@ function planTypeClass(planType?: string) {
|
||||
<AccountOverviewCards :summary="accountSummary" />
|
||||
|
||||
<BaseCard
|
||||
v-loading="initialLoading"
|
||||
:padded="false"
|
||||
class="mt-4 flex min-h-0 flex-1 flex-col"
|
||||
header-class="px-4 pt-4 pb-2 md:px-5"
|
||||
@@ -298,8 +298,10 @@ function planTypeClass(planType?: string) {
|
||||
<AccountCreateModal
|
||||
v-model="showCreateModal"
|
||||
v-model:form="createForm"
|
||||
:oauth-loading="authorizingOAuth"
|
||||
:saving="creatingAccount"
|
||||
@create="handleCreate"
|
||||
@generate-oauth="handleAuthorizeOAuth"
|
||||
/>
|
||||
|
||||
<AccountEditModal
|
||||
|
||||
@@ -71,7 +71,6 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
|
||||
</header>
|
||||
|
||||
<BaseCard
|
||||
v-loading="loading"
|
||||
:padded="false"
|
||||
class="mt-5 flex min-h-0 flex-1 flex-col"
|
||||
header-class="px-5 pt-4"
|
||||
@@ -91,6 +90,7 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
|
||||
<BaseTable
|
||||
:columns="apiKeyColumns"
|
||||
:rows="pagedKeys"
|
||||
:loading="loading"
|
||||
:selected-row-keys="selectedRowKeys"
|
||||
:pagination="apiKeyPagination"
|
||||
empty-text="暂无 API Key"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, onMounted, ref, type Ref } from 'vue'
|
||||
import { onMounted, ref, type Ref } from 'vue'
|
||||
|
||||
import { clearLogs, getLogs } from '@/api'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
@@ -16,10 +16,6 @@ export function useLogsTable(options: {
|
||||
const showClearModal = ref(false)
|
||||
const refreshingList = ref(false)
|
||||
const clearingLogs = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const initialLoading = computed(() => loading.value && !loaded.value)
|
||||
|
||||
async function loadLogs() {
|
||||
try {
|
||||
loading.value = true
|
||||
@@ -42,7 +38,6 @@ export function useLogsTable(options: {
|
||||
toast.error(error.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +78,6 @@ export function useLogsTable(options: {
|
||||
showClearModal,
|
||||
refreshingList,
|
||||
clearingLogs,
|
||||
initialLoading,
|
||||
loadLogs,
|
||||
refreshLogs,
|
||||
handleClearLogs,
|
||||
|
||||
@@ -34,7 +34,6 @@ const {
|
||||
showClearModal,
|
||||
refreshingList,
|
||||
clearingLogs,
|
||||
initialLoading,
|
||||
loadLogs,
|
||||
refreshLogs,
|
||||
handleClearLogs,
|
||||
@@ -65,7 +64,6 @@ bindLogLoader(loadLogs)
|
||||
</header>
|
||||
|
||||
<BaseCard
|
||||
v-loading="initialLoading"
|
||||
:padded="false"
|
||||
class="mt-5 flex min-h-0 flex-1 flex-col"
|
||||
header-class="px-5 pt-4"
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Gauge, GitBranch, Plus, RefreshCw, Save, Timer, Trash2, Zap } from '@lucide/vue'
|
||||
import { Gauge, GitBranch, Plus, Save, Timer, Trash2, Zap } from '@lucide/vue'
|
||||
|
||||
import { getSettings, updateSettings } from '@/api'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
import { withMinimumDuration } from '@/utils/async'
|
||||
|
||||
type RotationStrategy = 'least_used' | 'round_robin' | 'sticky'
|
||||
|
||||
@@ -26,7 +25,6 @@ interface SettingsForm {
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const refreshing = ref(false)
|
||||
const saving = ref(false)
|
||||
const aliasError = ref('')
|
||||
|
||||
@@ -150,18 +148,8 @@ async function loadSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSettings() {
|
||||
if (refreshing.value || loading.value) return
|
||||
refreshing.value = true
|
||||
try {
|
||||
await withMinimumDuration(loadSettings)
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (saving.value || loading.value || refreshing.value) return
|
||||
if (saving.value || loading.value) return
|
||||
|
||||
const aliasResult = rowsToAliases(aliasRows.value)
|
||||
if (aliasResult.error) {
|
||||
@@ -204,16 +192,10 @@ onMounted(loadSettings)
|
||||
</div>
|
||||
|
||||
<div class="mt-0.5 flex shrink-0 items-center gap-2">
|
||||
<BaseButton variant="ghost" :disabled="loading || refreshing || saving" @click="refreshSettings">
|
||||
<template #icon>
|
||||
<RefreshCw class="size-4" :class="refreshing ? 'animate-spin' : undefined" />
|
||||
</template>
|
||||
刷新
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
:loading="saving"
|
||||
:disabled="loading || refreshing"
|
||||
:disabled="loading"
|
||||
@click="handleSave"
|
||||
>
|
||||
<template #icon>
|
||||
@@ -226,14 +208,13 @@ onMounted(loadSettings)
|
||||
|
||||
<div class="mt-5 grid max-w-6xl gap-5">
|
||||
<BaseCard
|
||||
v-loading="loading"
|
||||
:padded="false"
|
||||
title="运行参数"
|
||||
description="请求节奏、账号并发和 Token 刷新。"
|
||||
header-class="px-5 pt-4"
|
||||
body-class="px-5 py-5"
|
||||
>
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="grid items-start gap-4 lg:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<span class="text-xs leading-[1.15] font-bold text-(--cp-text-secondary)">
|
||||
单账号最大并发
|
||||
@@ -287,7 +268,6 @@ onMounted(loadSettings)
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard
|
||||
v-loading="loading"
|
||||
:padded="false"
|
||||
title="模型映射"
|
||||
description="把客户端可见名称指向真实上游模型。"
|
||||
@@ -313,7 +293,7 @@ onMounted(loadSettings)
|
||||
<div
|
||||
v-for="(row, index) in aliasRows"
|
||||
:key="index"
|
||||
class="grid items-start gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_2.5rem]"
|
||||
class="grid items-center gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_2.5rem]"
|
||||
>
|
||||
<BaseInput
|
||||
:model-value="row.alias"
|
||||
@@ -356,7 +336,6 @@ onMounted(loadSettings)
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard
|
||||
v-loading="loading"
|
||||
:padded="false"
|
||||
title="账号选择"
|
||||
description="决定每次请求如何使用账号池。"
|
||||
|
||||
Reference in New Issue
Block a user