diff --git a/config.yaml b/config.yaml index 15aeaa0d8..956b5418d 100644 --- a/config.yaml +++ b/config.yaml @@ -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' diff --git a/src/admin/accounts/routes.rs b/src/admin/accounts/routes.rs index 90c3c8085..69684f2d5 100644 --- a/src/admin/accounts/routes.rs +++ b/src/admin/accounts/routes.rs @@ -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, } +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AccountOAuthExchangeRequest { + session_id: String, + callback_url: Option, + code: Option, + state: Option, +} + #[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, + Extension(request_id): Extension, + headers: HeaderMap, +) -> Result { + 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, + Extension(request_id): Extension, + headers: HeaderMap, + Json(payload): Json, +) -> Result { + 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, @@ -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) diff --git a/src/admin/accounts/service/mod.rs b/src/admin/accounts/service/mod.rs index bcefaf7e8..95f187c74 100644 --- a/src/admin/accounts/service/mod.rs +++ b/src/admin/accounts/service/mod.rs @@ -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, pub(crate) account_pool: StdArc, pub(crate) token_refresher: StdArc, + pub(crate) oauth: oauth::AccountOAuthService, pub(crate) refresh_margin_seconds: u64, pub(crate) installation_id: Option, } @@ -49,6 +52,7 @@ impl AdminAccountService { codex: StdArc, account_pool: StdArc, token_refresher: StdArc, + oauth: oauth::AccountOAuthService, refresh_margin_seconds: u64, installation_id: Option, ) -> Self { @@ -59,6 +63,7 @@ impl AdminAccountService { codex, account_pool, token_refresher, + oauth, refresh_margin_seconds, installation_id, } diff --git a/src/admin/accounts/service/oauth.rs b/src/admin/accounts/service/oauth.rs new file mode 100644 index 000000000..6c7275efd --- /dev/null +++ b/src/admin/accounts/service/oauth.rs @@ -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>>, +} + +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 { + 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 { + 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::(&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 { + 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 { + self.oauth.authorize().await + } + + pub async fn oauth_exchange( + &self, + input: OAuthExchangeInput, + ) -> Result { + 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, +} + +#[derive(Debug, Deserialize)] +struct AccountOAuthTokens { + access_token: String, + refresh_token: Option, +} + +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 { + 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}") +} diff --git a/src/admin/accounts/service/types.rs b/src/admin/accounts/service/types.rs index f378b0b99..cb868be36 100644 --- a/src/admin/accounts/service/types.rs +++ b/src/admin/accounts/service/types.rs @@ -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, +} + +#[derive(Debug, Clone)] +pub struct OAuthExchangeInput { + pub session_id: String, + pub callback_url: Option, + pub code: Option, + pub state: Option, +} + #[derive(Debug, Clone)] pub(super) struct ManualCreateTokens { pub(super) access_token: String, diff --git a/src/admin/router.rs b/src/admin/router.rs index fde97cc3d..b40f27247 100644 --- a/src/admin/router.rs +++ b/src/admin/router.rs @@ -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 { .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", diff --git a/src/runtime/services.rs b/src/runtime/services.rs index 620553dc1..21d1189c7 100644 --- a/src/runtime/services.rs +++ b/src/runtime/services.rs @@ -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(), )); diff --git a/tests/admin/accounts/mod.rs b/tests/admin/accounts/mod.rs index c8c519dc7..6611246e0 100644 --- a/tests/admin/accounts/mod.rs +++ b/tests/admin/accounts/mod.rs @@ -34,6 +34,7 @@ use tower::util::ServiceExt; mod import_export; mod lifecycle; mod list; +mod oauth; mod quota; mod testing; diff --git a/tests/admin/accounts/oauth.rs b/tests/admin/accounts/oauth.rs new file mode 100644 index 000000000..ecbcaf2cf --- /dev/null +++ b/tests/admin/accounts/oauth.rs @@ -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() +} diff --git a/web/src/api/modules/accounts.ts b/web/src/api/modules/accounts.ts index 29b45508e..05acf0eef 100644 --- a/web/src/api/modules/accounts.ts +++ b/web/src/api/modules/accounts.ts @@ -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', diff --git a/web/src/components/base/BaseButton.vue b/web/src/components/base/BaseButton.vue index b11b8b563..41e2bfc83 100644 --- a/web/src/components/base/BaseButton.vue +++ b/web/src/components/base/BaseButton.vue @@ -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, ]) diff --git a/web/src/components/base/BaseTable/index.vue b/web/src/components/base/BaseTable/index.vue index ce1cb6620..00f781c67 100644 --- a/web/src/components/base/BaseTable/index.vue +++ b/web/src/components/base/BaseTable/index.vue @@ -323,7 +323,7 @@ function paginationPageClass(page: number) { @mouseenter="horizontalHovering = true" @mouseleave="horizontalHovering = false" > -
+
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('form', { required: true }) const emit = defineEmits<{ create: [] + generateOauth: [] }>() const fileInput = useTemplateRef('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('复制失败') +}