diff --git a/backend/src/admin/accounts/routes.rs b/backend/src/admin/accounts/routes.rs index 746a5a5dd..61b85bdf9 100644 --- a/backend/src/admin/accounts/routes.rs +++ b/backend/src/admin/accounts/routes.rs @@ -127,6 +127,7 @@ struct AdminAccountData { user_id: Option, label: Option, plan_type: Option, + has_refresh_token: bool, status: String, access_token_expires_at: Option, access_token_expires_at_display: Option, @@ -161,6 +162,7 @@ impl AdminAccountData { user_id: a.user_id, label: a.label, plan_type: a.plan_type, + has_refresh_token: a.has_refresh_token, status: account_status_str(a.status).to_string(), access_token_expires_at, access_token_expires_at_display, diff --git a/backend/src/admin/accounts/service/contracts.rs b/backend/src/admin/accounts/service/contracts.rs index 06d5b0a12..ba5c1c933 100644 --- a/backend/src/admin/accounts/service/contracts.rs +++ b/backend/src/admin/accounts/service/contracts.rs @@ -20,6 +20,7 @@ pub struct AdminAccountMetadata { pub user_id: Option, pub label: Option, pub plan_type: Option, + pub has_refresh_token: bool, pub status: AcctStatus, pub access_token_expires_at: Option>, pub added_at: DateTime, @@ -43,6 +44,7 @@ impl From for AdminAccountMetadata { user_id: m.user_id, label: m.label, plan_type: m.plan_type, + has_refresh_token: m.has_refresh_token, status: m.status, access_token_expires_at: m.access_token_expires_at, added_at, @@ -282,6 +284,7 @@ pub(super) fn stored_to_admin_metadata( user_id: s.user_id, label: s.label, plan_type: s.plan_type, + has_refresh_token: s.refresh_token.is_some(), access_token_expires_at: s.access_token_expires_at, status: s.status, added_at: s.added_at, diff --git a/backend/src/upstream/accounts/store/mod.rs b/backend/src/upstream/accounts/store/mod.rs index 526546daf..4fd2d27fc 100644 --- a/backend/src/upstream/accounts/store/mod.rs +++ b/backend/src/upstream/accounts/store/mod.rs @@ -163,6 +163,8 @@ pub struct StoredAccountMetadata { pub label: Option, /// 计划类型。 pub plan_type: Option, + /// 是否保存了 refresh token。 + pub has_refresh_token: bool, /// access token 过期时间。 pub access_token_expires_at: Option>, /// 账号状态。 diff --git a/backend/src/upstream/accounts/store/queries.rs b/backend/src/upstream/accounts/store/queries.rs index 2c9359ef6..47982fd61 100644 --- a/backend/src/upstream/accounts/store/queries.rs +++ b/backend/src/upstream/accounts/store/queries.rs @@ -149,6 +149,7 @@ select chatgpt_user_id as user_id, label, plan_type, + case when refresh_token is not null and refresh_token <> '' then 1 else 0 end as has_refresh_token, access_token_expires_at, status, added_at, @@ -216,6 +217,7 @@ select chatgpt_user_id as user_id, label, plan_type, + case when refresh_token is not null and refresh_token <> '' then 1 else 0 end as has_refresh_token, access_token_expires_at, status, added_at, @@ -234,6 +236,7 @@ select chatgpt_user_id as user_id, label, plan_type, + case when refresh_token is not null and refresh_token <> '' then 1 else 0 end as has_refresh_token, access_token_expires_at, status, added_at, @@ -250,6 +253,7 @@ select chatgpt_user_id as user_id, label, plan_type, + case when refresh_token is not null and refresh_token <> '' then 1 else 0 end as has_refresh_token, access_token_expires_at, status, added_at, diff --git a/backend/src/upstream/accounts/store/rows.rs b/backend/src/upstream/accounts/store/rows.rs index bc344f1cc..ab9d63f0c 100644 --- a/backend/src/upstream/accounts/store/rows.rs +++ b/backend/src/upstream/accounts/store/rows.rs @@ -169,6 +169,7 @@ pub(super) fn metadata_from_row( user_id: row.get("user_id"), label: row.get("label"), plan_type: row.get("plan_type"), + has_refresh_token: row.get::("has_refresh_token") != 0, access_token_expires_at: parse_optional_rfc3339( row.get::, _>("access_token_expires_at") .as_deref(), diff --git a/backend/tests/admin/accounts/list.rs b/backend/tests/admin/accounts/list.rs index 94d52a732..4ed5523a9 100644 --- a/backend/tests/admin/accounts/list.rs +++ b/backend/tests/admin/accounts/list.rs @@ -11,6 +11,10 @@ async fn admin_accounts_list_should_not_expose_account_tokens() { .bind("acct_plain").bind("user@example.com").bind("plain-access-token") .bind("active").bind("2026-06-18T00:00:00Z").bind("2026-06-18T00:00:00Z") .execute(&pool).await.unwrap(); + sqlx::query("insert into accounts (id, email, access_token, refresh_token, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?, ?)") + .bind("acct_refresh").bind("refresh@example.com").bind("refresh-access-token").bind("refresh-secret-token") + .bind("active").bind("2026-06-19T00:00:00Z").bind("2026-06-19T00:00:00Z") + .execute(&pool).await.unwrap(); let config = test_config(url); let stores = BackgroundTaskStores { accounts: SqliteAccountStore::new(pool.clone()), @@ -45,12 +49,25 @@ async fn admin_accounts_list_should_not_expose_account_tokens() { assert_eq!(response.status(), StatusCode::OK); let body = response_json(response).await; - assert_eq!(body["data"]["items"][0]["id"], "acct_plain"); - assert_eq!(body["data"]["items"][0]["email"], "user@example.com"); - assert_eq!( - body["data"]["items"][0]["addedAt"], - "2026-06-18T08:00:00+08:00" - ); + let items = body["data"]["items"].as_array().unwrap(); + let plain = items + .iter() + .find(|item| item["id"] == "acct_plain") + .unwrap(); + let refresh = items + .iter() + .find(|item| item["id"] == "acct_refresh") + .unwrap(); + assert_eq!(plain["email"], "user@example.com"); + assert_eq!(plain["hasRefreshToken"], false); + assert_eq!(plain["addedAt"], "2026-06-18T08:00:00+08:00"); + assert_eq!(refresh["email"], "refresh@example.com"); + assert_eq!(refresh["hasRefreshToken"], true); + + let response_body = body.to_string(); + assert!(!response_body.contains("plain-access-token")); + assert!(!response_body.contains("refresh-access-token")); + assert!(!response_body.contains("refresh-secret-token")); } #[tokio::test] diff --git a/backend/tests/upstream/accounts/account_repository.rs b/backend/tests/upstream/accounts/account_repository.rs index c4e1e51c3..00d5d8f5b 100644 --- a/backend/tests/upstream/accounts/account_repository.rs +++ b/backend/tests/upstream/accounts/account_repository.rs @@ -103,6 +103,43 @@ async fn account_repository_should_list_metadata_without_exposing_tokens() { assert_eq!(page.items[0].status, AccountStatus::Active); } +#[tokio::test] +async fn account_repository_should_list_pool_accounts_with_tokens_and_plan() { + let (pool, _dir) = sqlite_account_store_parts("accounts.sqlite", 39).await; + let now = Utc::now().to_rfc3339(); + sqlx::query( + "insert into accounts ( + id, email, chatgpt_account_id, chatgpt_user_id, label, plan_type, access_token, refresh_token, + access_token_expires_at, status, added_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind("acct_1") + .bind("user@example.com") + .bind("chatgpt-account") + .bind(Option::::None) + .bind("primary") + .bind("plus") + .bind("access-token") + .bind(Some("refresh-token")) + .bind(Option::::None) + .bind("active") + .bind(&now) + .bind(&now) + .execute(&pool) + .await + .expect("insert account"); + + let store = SqliteAccountStore::new(pool); + let accounts = AccountStore::list_pool_accounts(&store) + .await + .expect("list accounts"); + + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0].id, "acct_1"); + assert_eq!(accounts[0].access_token, "access-token"); + assert_eq!(accounts[0].plan_type.as_deref(), Some("plus")); +} + #[tokio::test] async fn account_repository_should_update_status_and_label_without_rewriting_tokens() { let (pool, _dir) = sqlite_account_store_parts("accounts.sqlite", 6).await; diff --git a/backend/tests/upstream/fingerprint_integration/mod.rs b/backend/tests/upstream/fingerprint_integration/mod.rs deleted file mode 100644 index c3c1ad4ad..000000000 --- a/backend/tests/upstream/fingerprint_integration/mod.rs +++ /dev/null @@ -1,33 +0,0 @@ -use codex_proxy_rs::infra::database::connect_sqlite; -use codex_proxy_rs::upstream::models::backend_entry::BackendModelEntry; -use codex_proxy_rs::upstream::models::snapshot::ModelPlanSnapshot; -use codex_proxy_rs::upstream::models::snapshot_store::ModelSnapshotStore as _; -use codex_proxy_rs::upstream::models::snapshot_store::SqliteModelSnapshotStore; - -#[tokio::test] -async fn runtime_state_should_expose_backend_model_snapshot_through_model_service() { - let dir = tempfile::tempdir().expect("temp dir"); - let db = dir.path().join("runtime-models.sqlite"); - let pool = connect_sqlite(&format!("sqlite://{}", db.display())) - .await - .expect("sqlite pool"); - let snapshot_store = SqliteModelSnapshotStore::new(pool.clone()); - snapshot_store - .replace_plan_snapshot(&ModelPlanSnapshot::from_backend_entries( - "plus", - vec![BackendModelEntry { - id: Some("gpt-6".to_string()), - name: Some("GPT-6".to_string()), - ..BackendModelEntry::default() - }], - )) - .await - .expect("replace snapshot"); - - let loaded = snapshot_store - .list_plan_snapshots() - .await - .expect("list snapshots"); - assert_eq!(loaded.len(), 1); - assert_eq!(loaded[0].models[0].id, "gpt-6"); -} diff --git a/backend/tests/upstream/mod.rs b/backend/tests/upstream/mod.rs index b5c19d8d8..562f0f0c4 100644 --- a/backend/tests/upstream/mod.rs +++ b/backend/tests/upstream/mod.rs @@ -1,6 +1,5 @@ pub(crate) mod accounts; pub(crate) mod fingerprint; -pub(crate) mod fingerprint_integration; pub(crate) mod models; pub(crate) mod protocol; pub(crate) mod token_client; diff --git a/backend/tests/upstream/models/store.rs b/backend/tests/upstream/models/store.rs index eb4251ea5..a2a507886 100644 --- a/backend/tests/upstream/models/store.rs +++ b/backend/tests/upstream/models/store.rs @@ -1,14 +1,7 @@ -use chrono::Utc; -use codex_proxy_rs::upstream::{ - accounts::{ - model::AccountStatus, - store::{AccountStore, SqliteAccountStore}, - }, - models::{ - backend_entry::BackendModelEntry, - snapshot::ModelPlanSnapshot, - snapshot_store::{ModelSnapshotStore, SqliteModelSnapshotStore}, - }, +use codex_proxy_rs::upstream::models::{ + backend_entry::BackendModelEntry, + snapshot::ModelPlanSnapshot, + snapshot_store::{ModelSnapshotStore, SqliteModelSnapshotStore}, }; #[tokio::test] @@ -41,50 +34,3 @@ async fn model_snapshot_repository_should_replace_and_load_plan_snapshots() { assert_eq!(loaded[0].models[0].id, "gpt-team"); assert_eq!(loaded[0].models[0].source, "backend"); } - -#[tokio::test] -async fn sqlite_account_store_should_list_pool_accounts() { - let dir = tempfile::tempdir().expect("temp dir"); - let db = dir.path().join("accounts.sqlite"); - let pool = - codex_proxy_rs::infra::database::connect_sqlite(&format!("sqlite://{}", db.display())) - .await - .expect("sqlite pool"); - let now = Utc::now().to_rfc3339(); - let access_token = "access-token"; - let refresh_token = "refresh-token"; - sqlx::query( - "insert into accounts ( - id, email, chatgpt_account_id, chatgpt_user_id, label, plan_type, access_token, refresh_token, - access_token_expires_at, status, added_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind("acct_1") - .bind("user@example.com") - .bind("chatgpt-account") - .bind(Option::::None) - .bind("primary") - .bind("plus") - .bind(access_token) - .bind(Some(refresh_token)) - .bind(Option::::None) - .bind(match AccountStatus::Active { - AccountStatus::Active => "active", - _ => unreachable!(), - }) - .bind(&now) - .bind(&now) - .execute(&pool) - .await - .expect("insert account"); - - let store = SqliteAccountStore::new(pool); - let accounts = AccountStore::list_pool_accounts(&store) - .await - .expect("list accounts"); - - assert_eq!(accounts.len(), 1); - assert_eq!(accounts[0].id, "acct_1"); - assert_eq!(accounts[0].access_token, "access-token"); - assert_eq!(accounts[0].plan_type.as_deref(), Some("plus")); -} diff --git a/frontend/src/components/base/BaseScrollbar.vue b/frontend/src/components/base/BaseScrollbar.vue index 23b8944c5..0af75c9d0 100644 --- a/frontend/src/components/base/BaseScrollbar.vue +++ b/frontend/src/components/base/BaseScrollbar.vue @@ -10,6 +10,7 @@ const props = withDefaults( height?: string horizontal?: boolean vertical?: boolean + trackInset?: 'default' | 'none' }>(), { viewClass: '', @@ -17,6 +18,7 @@ const props = withDefaults( height: undefined, horizontal: false, vertical: true, + trackInset: 'default', }, ) @@ -74,6 +76,12 @@ const verticalTrackClass = computed(() => const horizontalTrackClass = computed(() => props.vertical && canScrollY.value ? 'right-3' : 'right-1', ) +const verticalTrackInsetClass = computed(() => + props.trackInset === 'none' ? 'right-0' : 'right-1', +) +const horizontalTrackInsetClass = computed(() => + props.trackInset === 'none' ? 'bottom-0 left-0' : 'bottom-1 left-1', +) function trackHeight(wrap: HTMLElement) { return clamp(wrap.clientHeight - 8, 0, Number.POSITIVE_INFINITY) @@ -389,8 +397,12 @@ defineExpose({
-import { computed } from 'vue' +import { computed, useSlots } from 'vue' import BaseEmpty from '../BaseEmpty.vue' import BaseScrollbar from '../BaseScrollbar.vue' @@ -51,6 +51,7 @@ const emit = defineEmits<{ 'page-change': [page: number] 'page-size-change': [pageSize: number] }>() +const slots = useSlots() const headerRowClass = 'h-10 text-[11px] font-bold text-(--cp-text-muted)' const bodyRowClass = 'h-13' @@ -140,6 +141,10 @@ function rowClass(row: TableRow, index: number) { function isLastColumn(index: number) { return index === computedColumns.value.length - 1 } + +function bodyCellTitle(column: BaseTableColumn, row: TableRow) { + return slots[column.key] ? undefined : cellTitle(column, row) +}