mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(codex): write managed ChatGPT id_token into live auth + reauth prompt
Bind the selected ChatGPT account's id_token into the Codex official managed auth.json so it matches a native browser login for the fields that drive behavior (auth_mode, account/plan/email via id_token claims). - Persist id_token on CodexAccountData; capture at login and on refresh (empty string treated as missing). Add get_valid_token_and_id_token_for_account. - codex_managed_oauth_live_auth now writes tokens.id_token when available. - Keep the managed-vs-native safety marker intact: extract_codex_managed_oauth_auth tolerates id_token but still rejects native logins (which carry refresh_token / top-level last_refresh), so cc-switch never clears a real browser login. refresh_token and last_refresh are intentionally NOT written for this reason. - Surface reauth_required (account has no stored id_token) through GitHubAccount -> ManagedAuthAccount -> TS. Legacy accounts get a styled amber prompt + one-click re-login (device flow) in CodexOAuthSection, flagged in both the selector dropdown and the select-mode hint. - i18n: reauth strings added for en / ja / zh / zh-TW. Verified: pnpm typecheck + build:renderer green; cross-model review of Rust correctness, the managed-vs-native safety invariant, and the id_token lifecycle.
This commit is contained in:
@@ -86,9 +86,11 @@ fn extract_codex_managed_oauth_auth(auth: &Value) -> Option<(String, String)> {
|
||||
|
||||
let tokens = auth.get("tokens").and_then(|value| value.as_object())?;
|
||||
|
||||
// id_token 与原生浏览器登录一致,允许存在;但原生登录特有的
|
||||
// refresh_token 等字段仍会被拒绝,从而保留「托管 vs 原生」的指纹区分。
|
||||
if tokens
|
||||
.keys()
|
||||
.any(|key| !matches!(key.as_str(), "access_token" | "account_id"))
|
||||
.any(|key| !matches!(key.as_str(), "access_token" | "account_id" | "id_token"))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -1792,6 +1794,48 @@ base_url = "https://single.example.com/v1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn recorded_managed_oauth_marker_tolerates_id_token_but_rejects_native_login_shape() {
|
||||
let _home = TempHome::new();
|
||||
// 托管写入:tokens 仅含 id_token + access_token + account_id
|
||||
let managed_auth_with_id_token = json!({
|
||||
"auth_mode": "chatgpt",
|
||||
"OPENAI_API_KEY": null,
|
||||
"tokens": {
|
||||
"id_token": "managed-id-token",
|
||||
"access_token": "managed-token",
|
||||
"account_id": "acct-managed"
|
||||
}
|
||||
});
|
||||
// 原生浏览器登录:即使 access_token 巧合相同,也带有 refresh_token
|
||||
// 与顶层 last_refresh,必须被判定为非托管、永不清除。
|
||||
let native_login_auth = json!({
|
||||
"auth_mode": "chatgpt",
|
||||
"OPENAI_API_KEY": null,
|
||||
"tokens": {
|
||||
"id_token": "native-id-token",
|
||||
"access_token": "managed-token",
|
||||
"refresh_token": "native-refresh-token",
|
||||
"account_id": "acct-managed"
|
||||
},
|
||||
"last_refresh": "2026-01-01T00:00:00Z"
|
||||
});
|
||||
|
||||
record_codex_managed_oauth_live_auth(&managed_auth_with_id_token).expect("record marker");
|
||||
|
||||
assert!(
|
||||
codex_auth_matches_recorded_managed_oauth(&managed_auth_with_id_token, "acct-managed")
|
||||
.expect("managed auth with id_token should match"),
|
||||
"an id_token alongside access_token/account_id must still be treated as managed"
|
||||
);
|
||||
assert!(
|
||||
!codex_auth_matches_recorded_managed_oauth(&native_login_auth, "acct-managed")
|
||||
.expect("native login should not match"),
|
||||
"a real browser login (refresh_token + last_refresh) must never be treated as managed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_provider_live_config_uses_top_level_token_for_reserved_provider() {
|
||||
let input = r#"model_provider = "openai"
|
||||
|
||||
@@ -19,6 +19,8 @@ pub struct ManagedAuthAccount {
|
||||
pub authenticated_at: i64,
|
||||
pub is_default: bool,
|
||||
pub github_domain: String,
|
||||
/// 托管账号是否需要重新登录以补全缺失的凭据(Codex 旧账号缺少 id_token)
|
||||
pub reauth_required: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
@@ -55,6 +57,7 @@ fn map_account(
|
||||
) -> ManagedAuthAccount {
|
||||
ManagedAuthAccount {
|
||||
is_default: default_account_id == Some(account.id.as_str()),
|
||||
reauth_required: account.reauth_required,
|
||||
id: account.id,
|
||||
provider: provider.to_string(),
|
||||
login: account.login,
|
||||
|
||||
@@ -189,6 +189,10 @@ struct CodexAccountData {
|
||||
pub refresh_token: String,
|
||||
/// 认证时间戳(秒)
|
||||
pub authenticated_at: i64,
|
||||
/// ChatGPT id_token(JWT,持久化)。用于让托管写入的 Codex auth.json
|
||||
/// 与原生浏览器登录保持一致的 tokens 字段形状;刷新时若返回新值则更新。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub id_token: Option<String>,
|
||||
}
|
||||
|
||||
/// 公开的账号信息(返回给前端,复用 GitHubAccount 结构)
|
||||
@@ -204,6 +208,8 @@ impl From<&CodexAccountData> for GitHubAccount {
|
||||
avatar_url: None,
|
||||
authenticated_at: data.authenticated_at,
|
||||
github_domain: "github.com".to_string(),
|
||||
// 旧账号(升级前登录)没有持久化 id_token,需重新登录补全
|
||||
reauth_required: data.id_token.is_none(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -419,7 +425,13 @@ impl CodexOAuthManager {
|
||||
}
|
||||
|
||||
let account = self
|
||||
.add_account_internal(account_id, refresh_token, email)
|
||||
.add_account_internal(
|
||||
account_id,
|
||||
refresh_token,
|
||||
email,
|
||||
// 空字符串视为缺失,避免写出空的 id_token
|
||||
tokens.id_token.clone().filter(|t| !t.trim().is_empty()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Some(account))
|
||||
@@ -539,17 +551,34 @@ impl CodexOAuthManager {
|
||||
|
||||
let new_tokens = self.refresh_with_token(&refresh_token).await?;
|
||||
|
||||
// 如果服务端返回了新的 refresh_token,更新存储
|
||||
if let Some(new_refresh) = new_tokens.refresh_token.clone() {
|
||||
if new_refresh != refresh_token {
|
||||
let mut accounts = self.accounts.write().await;
|
||||
if let Some(account) = accounts.get_mut(account_id) {
|
||||
account.refresh_token = new_refresh;
|
||||
// 如果服务端返回了新的 refresh_token 或 id_token,更新存储
|
||||
let mut needs_save = false;
|
||||
{
|
||||
let mut accounts = self.accounts.write().await;
|
||||
if let Some(account) = accounts.get_mut(account_id) {
|
||||
if let Some(new_refresh) = new_tokens.refresh_token.clone() {
|
||||
if new_refresh != account.refresh_token {
|
||||
account.refresh_token = new_refresh;
|
||||
needs_save = true;
|
||||
}
|
||||
}
|
||||
// 刷新使用 openid scope,正常会返回新 id_token;为空则视为缺失,
|
||||
// 保留旧值而非覆盖(旧值的 claims 仍可用于账号/套餐显示)。
|
||||
if let Some(new_id_token) = new_tokens
|
||||
.id_token
|
||||
.clone()
|
||||
.filter(|token| !token.trim().is_empty())
|
||||
{
|
||||
if account.id_token.as_deref() != Some(new_id_token.as_str()) {
|
||||
account.id_token = Some(new_id_token);
|
||||
needs_save = true;
|
||||
}
|
||||
}
|
||||
drop(accounts);
|
||||
self.save_to_disk().await?;
|
||||
}
|
||||
}
|
||||
if needs_save {
|
||||
self.save_to_disk().await?;
|
||||
}
|
||||
|
||||
let access_token = new_tokens.access_token.clone();
|
||||
let expires_at_ms = compute_expires_at_ms(new_tokens.expires_in);
|
||||
@@ -568,6 +597,24 @@ impl CodexOAuthManager {
|
||||
Ok(access_token)
|
||||
}
|
||||
|
||||
/// 获取指定账号的有效 access_token 与 id_token(必要时自动刷新)
|
||||
///
|
||||
/// id_token 用于让托管写入的 Codex auth.json 与原生浏览器登录保持
|
||||
/// 一致的 tokens 字段形状(仅托管绑定路径使用)。旧账号若无 id_token
|
||||
/// 会返回 `None`,前端据此提示重新登录。
|
||||
pub async fn get_valid_token_and_id_token_for_account(
|
||||
&self,
|
||||
account_id: &str,
|
||||
) -> Result<(String, Option<String>), CodexOAuthError> {
|
||||
// 先确保 access_token 有效;刷新过程会顺带更新持久化的 id_token
|
||||
let access_token = self.get_valid_token_for_account(account_id).await?;
|
||||
let id_token = {
|
||||
let accounts = self.accounts.read().await;
|
||||
accounts.get(account_id).and_then(|a| a.id_token.clone())
|
||||
};
|
||||
Ok((access_token, id_token))
|
||||
}
|
||||
|
||||
/// 获取默认账号的有效 token
|
||||
pub async fn get_valid_token(&self) -> Result<String, CodexOAuthError> {
|
||||
match self.resolve_default_account_id().await {
|
||||
@@ -700,11 +747,13 @@ impl CodexOAuthManager {
|
||||
&self,
|
||||
account_id: &str,
|
||||
access_token: &str,
|
||||
id_token: Option<&str>,
|
||||
) -> Result<(), CodexOAuthError> {
|
||||
self.add_account_internal(
|
||||
account_id.to_string(),
|
||||
"test-refresh-token".to_string(),
|
||||
Some(format!("{account_id}@example.test")),
|
||||
id_token.map(|token| token.to_string()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -727,6 +776,7 @@ impl CodexOAuthManager {
|
||||
account_id: String,
|
||||
refresh_token: String,
|
||||
email: Option<String>,
|
||||
id_token: Option<String>,
|
||||
) -> Result<GitHubAccount, CodexOAuthError> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
@@ -735,6 +785,7 @@ impl CodexOAuthManager {
|
||||
email,
|
||||
refresh_token,
|
||||
authenticated_at: now,
|
||||
id_token,
|
||||
};
|
||||
|
||||
let account = GitHubAccount::from(&data);
|
||||
@@ -1116,6 +1167,7 @@ mod tests {
|
||||
"acc-123".to_string(),
|
||||
"rt-secret".to_string(),
|
||||
Some("user@example.com".to_string()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1138,6 +1190,7 @@ mod tests {
|
||||
"acc-123".to_string(),
|
||||
"rt".to_string(),
|
||||
Some("a@example.com".to_string()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1146,6 +1199,7 @@ mod tests {
|
||||
"acc-456".to_string(),
|
||||
"rt2".to_string(),
|
||||
Some("b@example.com".to_string()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -341,6 +341,10 @@ pub struct GitHubAccount {
|
||||
/// GitHub 域名(github.com 或 GHES 域名)
|
||||
#[serde(default = "default_github_domain")]
|
||||
pub github_domain: String,
|
||||
/// 托管账号是否需要重新登录以补全缺失的凭据。
|
||||
/// Codex:缺少持久化 id_token 的旧账号为 true;Copilot 恒为 false。
|
||||
#[serde(default)]
|
||||
pub reauth_required: bool,
|
||||
}
|
||||
|
||||
impl From<&GitHubAccountData> for GitHubAccount {
|
||||
@@ -351,6 +355,7 @@ impl From<&GitHubAccountData> for GitHubAccount {
|
||||
avatar_url: data.user.avatar_url.clone(),
|
||||
authenticated_at: data.authenticated_at,
|
||||
github_domain: data.github_domain.clone(),
|
||||
reauth_required: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -547,6 +552,7 @@ impl CopilotAuthManager {
|
||||
avatar_url: user.avatar_url.clone(),
|
||||
authenticated_at: now,
|
||||
github_domain,
|
||||
reauth_required: false,
|
||||
};
|
||||
|
||||
{
|
||||
@@ -1557,6 +1563,7 @@ mod tests {
|
||||
avatar_url: Some("https://example.com/avatar.png".to_string()),
|
||||
authenticated_at: 1234567890,
|
||||
github_domain: DEFAULT_GITHUB_DOMAIN.to_string(),
|
||||
reauth_required: false,
|
||||
}],
|
||||
default_account_id: Some("12345".to_string()),
|
||||
migration_error: None,
|
||||
|
||||
@@ -586,8 +586,9 @@ fn apply_codex_managed_oauth_auth(
|
||||
));
|
||||
};
|
||||
|
||||
let access_token = get_codex_managed_oauth_token(manager.clone(), account_id.clone())?;
|
||||
let auth = codex_managed_oauth_live_auth(&account_id, &access_token);
|
||||
let (access_token, id_token) =
|
||||
get_codex_managed_oauth_token(manager.clone(), account_id.clone())?;
|
||||
let auth = codex_managed_oauth_live_auth(&account_id, &access_token, id_token.as_deref());
|
||||
|
||||
let Some(settings_obj) = provider.settings_config.as_object_mut() else {
|
||||
return Err(AppError::Config(
|
||||
@@ -602,13 +603,13 @@ fn apply_codex_managed_oauth_auth(
|
||||
fn get_codex_managed_oauth_token(
|
||||
manager: Arc<RwLock<CodexOAuthManager>>,
|
||||
account_id: String,
|
||||
) -> Result<String, AppError> {
|
||||
) -> Result<(String, Option<String>), AppError> {
|
||||
let result = std::thread::spawn(move || {
|
||||
tauri::async_runtime::block_on(async move {
|
||||
let error_account_id = account_id.clone();
|
||||
let manager = manager.read().await;
|
||||
manager
|
||||
.get_valid_token_for_account(&account_id)
|
||||
.get_valid_token_and_id_token_for_account(&account_id)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
@@ -623,14 +624,28 @@ fn get_codex_managed_oauth_token(
|
||||
result.map_err(AppError::Message)
|
||||
}
|
||||
|
||||
pub(crate) fn codex_managed_oauth_live_auth(account_id: &str, access_token: &str) -> Value {
|
||||
pub(crate) fn codex_managed_oauth_live_auth(
|
||||
account_id: &str,
|
||||
access_token: &str,
|
||||
id_token: Option<&str>,
|
||||
) -> Value {
|
||||
// 与原生 Codex 浏览器登录的 tokens 字段顺序对齐:id_token 在前。
|
||||
let mut tokens = serde_json::Map::new();
|
||||
if let Some(id_token) = id_token {
|
||||
tokens.insert("id_token".to_string(), Value::String(id_token.to_string()));
|
||||
}
|
||||
tokens.insert(
|
||||
"access_token".to_string(),
|
||||
Value::String(access_token.to_string()),
|
||||
);
|
||||
tokens.insert(
|
||||
"account_id".to_string(),
|
||||
Value::String(account_id.to_string()),
|
||||
);
|
||||
json!({
|
||||
"auth_mode": "chatgpt",
|
||||
"OPENAI_API_KEY": null,
|
||||
"tokens": {
|
||||
"access_token": access_token,
|
||||
"account_id": account_id,
|
||||
},
|
||||
"tokens": Value::Object(tokens),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1799,7 +1814,24 @@ mod tests {
|
||||
#[test]
|
||||
fn codex_managed_oauth_live_auth_matches_codex_cli_shape() {
|
||||
assert_eq!(
|
||||
codex_managed_oauth_live_auth("acct-managed", "access-token"),
|
||||
codex_managed_oauth_live_auth("acct-managed", "access-token", Some("id-token")),
|
||||
json!({
|
||||
"auth_mode": "chatgpt",
|
||||
"OPENAI_API_KEY": null,
|
||||
"tokens": {
|
||||
"id_token": "id-token",
|
||||
"access_token": "access-token",
|
||||
"account_id": "acct-managed"
|
||||
}
|
||||
}),
|
||||
"managed live auth should include id_token to match native browser login"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_managed_oauth_live_auth_without_id_token_omits_it() {
|
||||
assert_eq!(
|
||||
codex_managed_oauth_live_auth("acct-managed", "access-token", None),
|
||||
json!({
|
||||
"auth_mode": "chatgpt",
|
||||
"OPENAI_API_KEY": null,
|
||||
@@ -1807,7 +1839,8 @@ mod tests {
|
||||
"access_token": "access-token",
|
||||
"account_id": "acct-managed"
|
||||
}
|
||||
})
|
||||
}),
|
||||
"without a stored id_token the field is omitted rather than written as null"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1821,7 +1854,11 @@ mod tests {
|
||||
manager
|
||||
.read()
|
||||
.await
|
||||
.add_test_account_with_access_token("acct-managed", "managed-token")
|
||||
.add_test_account_with_access_token(
|
||||
"acct-managed",
|
||||
"managed-token",
|
||||
Some("managed-id-token"),
|
||||
)
|
||||
.await
|
||||
.expect("seed managed account");
|
||||
});
|
||||
@@ -1854,7 +1891,8 @@ mod tests {
|
||||
provider.settings_config.get("auth"),
|
||||
Some(&codex_managed_oauth_live_auth(
|
||||
"acct-managed",
|
||||
"managed-token"
|
||||
"managed-token",
|
||||
Some("managed-id-token")
|
||||
)),
|
||||
"explicit account binding should write the managed ChatGPT token to Codex live auth"
|
||||
);
|
||||
@@ -1870,7 +1908,7 @@ mod tests {
|
||||
manager
|
||||
.read()
|
||||
.await
|
||||
.add_test_account_with_access_token("acct-managed", "managed-token")
|
||||
.add_test_account_with_access_token("acct-managed", "managed-token", None)
|
||||
.await
|
||||
.expect("seed managed account");
|
||||
});
|
||||
|
||||
@@ -886,7 +886,11 @@ base_url = "http://localhost:8080"
|
||||
.codex_oauth_manager
|
||||
.read()
|
||||
.await
|
||||
.add_test_account_with_access_token("acct-managed", "managed-token")
|
||||
.add_test_account_with_access_token(
|
||||
"acct-managed",
|
||||
"managed-token",
|
||||
Some("managed-id-token"),
|
||||
)
|
||||
.await
|
||||
.expect("seed managed Codex OAuth account");
|
||||
});
|
||||
|
||||
@@ -5079,7 +5079,11 @@ base_url = "https://codex.example/v1"
|
||||
.codex_oauth_manager
|
||||
.read()
|
||||
.await
|
||||
.add_test_account_with_access_token("acct-managed", "managed-token")
|
||||
.add_test_account_with_access_token(
|
||||
"acct-managed",
|
||||
"managed-token",
|
||||
Some("managed-id-token"),
|
||||
)
|
||||
.await
|
||||
.expect("seed managed Codex OAuth account");
|
||||
|
||||
@@ -5146,6 +5150,13 @@ base_url = "https://codex.example/v1"
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("acct-managed")
|
||||
);
|
||||
assert_eq!(
|
||||
stored
|
||||
.pointer("/auth/tokens/id_token")
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("managed-id-token"),
|
||||
"managed backup auth should carry the account id_token to match native login"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
Sparkles,
|
||||
User,
|
||||
Settings2,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { useCodexOauth } from "./hooks/useCodexOauth";
|
||||
import { copyText } from "@/lib/clipboard";
|
||||
@@ -118,6 +120,16 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 升级前登录的旧账号没有持久化 id_token,需重新登录补全
|
||||
const hasReauthAccounts = accounts.some(
|
||||
(account) => account.reauth_required,
|
||||
);
|
||||
const selectedAccountNeedsReauth =
|
||||
!!selectedAccountId &&
|
||||
accounts.some(
|
||||
(account) => account.id === selectedAccountId && account.reauth_required,
|
||||
);
|
||||
|
||||
const accountSelect = onAccountSelect &&
|
||||
(mode === "select" || hasAnyAccount || noneOptionLabel) && (
|
||||
<div className="space-y-2">
|
||||
@@ -150,6 +162,12 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{account.login}</span>
|
||||
{account.reauth_required && (
|
||||
<span className="ml-1 inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{t("codexOauth.reauthBadge", "需要重新登录")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -178,6 +196,24 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 旧账号需重新登录提示(缺少 id_token) */}
|
||||
{mode === "manage" && hasReauthAccounts && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-amber-300/70 bg-amber-50 p-3 text-amber-900 dark:border-amber-500/40 dark:bg-amber-950/40 dark:text-amber-100">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t("codexOauth.reauthTitle", "部分账号需要重新登录")}
|
||||
</p>
|
||||
<p className="text-xs leading-relaxed text-amber-800/90 dark:text-amber-200/80">
|
||||
{t(
|
||||
"codexOauth.reauthDescription",
|
||||
"为与浏览器登录行为保持一致,这些账号需要重新登录以补全所需的登录凭据(id_token)。重新登录后即可正常用于托管绑定。",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 账号选择器 */}
|
||||
{mode === "select" && accountSelect ? (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
|
||||
@@ -198,6 +234,28 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
||||
accountSelect
|
||||
)}
|
||||
|
||||
{/* select 模式:所选账号需重新登录的内联提示 */}
|
||||
{mode === "select" && selectedAccountNeedsReauth && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-300/70 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/40 dark:bg-amber-950/40 dark:text-amber-100">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||
<div className="flex-1 leading-relaxed">
|
||||
{t(
|
||||
"codexOauth.reauthSelectHint",
|
||||
"该账号需重新登录以启用托管绑定。",
|
||||
)}
|
||||
{onManageAccounts && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageAccounts}
|
||||
className="ml-1 font-medium underline underline-offset-2 hover:text-amber-700 dark:hover:text-amber-100"
|
||||
>
|
||||
{t("codexOauth.reauthNow", "立即重新登录")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onFastModeChange && (
|
||||
<div className="flex items-center justify-between rounded-md border bg-muted/30 p-3">
|
||||
<div className="space-y-1 pr-4">
|
||||
@@ -229,23 +287,51 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="flex items-center justify-between p-2 rounded-md border bg-muted/30"
|
||||
className={`flex items-center justify-between gap-2 p-2 rounded-md border ${
|
||||
account.reauth_required
|
||||
? "border-amber-300/70 bg-amber-50/70 dark:border-amber-500/40 dark:bg-amber-950/30"
|
||||
: "bg-muted/30"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{account.login}</span>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<User className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm font-medium">
|
||||
{account.login}
|
||||
</span>
|
||||
{defaultAccountId === account.id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||
{t("codexOauth.defaultAccount", "默认")}
|
||||
</Badge>
|
||||
)}
|
||||
{selectedAccountId === account.id && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Badge variant="outline" className="shrink-0 text-xs">
|
||||
{t("codexOauth.selected", "已选中")}
|
||||
</Badge>
|
||||
)}
|
||||
{account.reauth_required && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 gap-1 border-amber-400/70 text-xs text-amber-700 dark:border-amber-500/50 dark:text-amber-300"
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{t("codexOauth.reauthBadge", "需要重新登录")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{account.reauth_required && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 border-amber-400/70 px-2 text-xs text-amber-700 hover:bg-amber-100 dark:border-amber-500/50 dark:text-amber-300 dark:hover:bg-amber-900/40"
|
||||
onClick={addAccount}
|
||||
disabled={isAddingAccount}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
{t("codexOauth.reauthLogin", "重新登录")}
|
||||
</Button>
|
||||
)}
|
||||
{defaultAccountId !== account.id && (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -262,7 +348,7 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-red-500"
|
||||
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-red-500"
|
||||
onClick={(e) => handleRemoveAccount(account.id, e)}
|
||||
disabled={isRemovingAccount}
|
||||
title={t("codexOauth.removeAccount", "移除账号")}
|
||||
|
||||
@@ -1172,6 +1172,12 @@
|
||||
"selectAccountPlaceholder": "Choose a ChatGPT account",
|
||||
"useDefaultAccount": "Use default account",
|
||||
"noneOptionLabel": "Don't bind a managed account, use browser login",
|
||||
"reauthTitle": "Some accounts need to sign in again",
|
||||
"reauthDescription": "To match the browser-login behavior, these accounts must sign in again to restore the required login credential (id_token). Once re-authenticated they can be used for managed binding.",
|
||||
"reauthBadge": "Re-login required",
|
||||
"reauthLogin": "Re-login",
|
||||
"reauthSelectHint": "This account needs to sign in again to enable managed binding.",
|
||||
"reauthNow": "Re-login now",
|
||||
"loggedInAccounts": "Logged in accounts",
|
||||
"defaultAccount": "Default",
|
||||
"selected": "Selected",
|
||||
|
||||
@@ -1172,6 +1172,12 @@
|
||||
"selectAccountPlaceholder": "ChatGPT アカウントを選択",
|
||||
"useDefaultAccount": "デフォルトアカウントを使用",
|
||||
"noneOptionLabel": "管理アカウントをバインドせず、ブラウザでログイン",
|
||||
"reauthTitle": "再ログインが必要なアカウントがあります",
|
||||
"reauthDescription": "ブラウザログインと同じ挙動にするため、これらのアカウントは再ログインして必要な認証情報(id_token)を補完する必要があります。再ログイン後は管理アカウントのバインドに利用できます。",
|
||||
"reauthBadge": "再ログインが必要",
|
||||
"reauthLogin": "再ログイン",
|
||||
"reauthSelectHint": "このアカウントは管理バインドを有効にするため再ログインが必要です。",
|
||||
"reauthNow": "今すぐ再ログイン",
|
||||
"loggedInAccounts": "ログイン済みアカウント",
|
||||
"defaultAccount": "デフォルト",
|
||||
"selected": "選択中",
|
||||
|
||||
@@ -1144,6 +1144,12 @@
|
||||
"selectAccountPlaceholder": "選擇一個 ChatGPT 帳號",
|
||||
"useDefaultAccount": "使用預設帳號",
|
||||
"noneOptionLabel": "暫不綁定託管帳號,使用瀏覽器登入",
|
||||
"reauthTitle": "部分帳號需要重新登入",
|
||||
"reauthDescription": "為與瀏覽器登入行為保持一致,這些帳號需要重新登入以補全所需的登入憑證(id_token)。重新登入後即可正常用於託管綁定。",
|
||||
"reauthBadge": "需要重新登入",
|
||||
"reauthLogin": "重新登入",
|
||||
"reauthSelectHint": "該帳號需重新登入以啟用託管綁定。",
|
||||
"reauthNow": "立即重新登入",
|
||||
"loggedInAccounts": "已登入帳號",
|
||||
"defaultAccount": "預設",
|
||||
"selected": "已選取",
|
||||
|
||||
@@ -1172,6 +1172,12 @@
|
||||
"selectAccountPlaceholder": "选择一个 ChatGPT 账号",
|
||||
"useDefaultAccount": "使用默认账号",
|
||||
"noneOptionLabel": "暂不绑定托管账号,使用浏览器登录",
|
||||
"reauthTitle": "部分账号需要重新登录",
|
||||
"reauthDescription": "为与浏览器登录行为保持一致,这些账号需要重新登录以补全所需的登录凭据(id_token)。重新登录后即可正常用于托管绑定。",
|
||||
"reauthBadge": "需要重新登录",
|
||||
"reauthLogin": "重新登录",
|
||||
"reauthSelectHint": "该账号需重新登录以启用托管绑定。",
|
||||
"reauthNow": "立即重新登录",
|
||||
"loggedInAccounts": "已登录账号",
|
||||
"defaultAccount": "默认",
|
||||
"selected": "已选中",
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface ManagedAuthAccount {
|
||||
authenticated_at: number;
|
||||
is_default: boolean;
|
||||
github_domain: string;
|
||||
/** 是否需要重新登录以补全缺失的凭据(Codex 旧账号缺少 id_token) */
|
||||
reauth_required?: boolean;
|
||||
}
|
||||
|
||||
export interface ManagedAuthStatus {
|
||||
|
||||
Reference in New Issue
Block a user