diff --git a/backend/crates/gateway-api/src/admin/accounts/credentials.rs b/backend/crates/gateway-api/src/admin/accounts/credentials.rs index 2278ebaeb..e94dde111 100644 --- a/backend/crates/gateway-api/src/admin/accounts/credentials.rs +++ b/backend/crates/gateway-api/src/admin/accounts/credentials.rs @@ -117,9 +117,6 @@ impl StartAccountAuthorizationRequest { pub fn validate(&self) -> Result<(), WireValidationError> { parse_provider(&self.provider)?; validate_account_name(&self.name)?; - if self.account_id.is_none() && self.name.trim().is_empty() { - return Err(WireValidationError::new("name")); - } if let Some(account_id) = self.account_id.as_deref() { require_account_id(account_id, "accountId")?; } @@ -141,7 +138,7 @@ impl StartAccountAuthorizationRequest { provider, StartAuthorization { context, - name: self.name, + name: self.name.trim().to_owned(), reauthorization, outbound_proxy: super::wire::proxy_selection( self.outbound_proxy_id, diff --git a/backend/crates/gateway-api/tests/admin/accounts/mod.rs b/backend/crates/gateway-api/tests/admin/accounts/mod.rs index 8fe1e014b..55f0560f2 100644 --- a/backend/crates/gateway-api/tests/admin/accounts/mod.rs +++ b/backend/crates/gateway-api/tests/admin/accounts/mod.rs @@ -767,6 +767,24 @@ mod actions { } } + #[test] + fn authorization_accepts_optional_names_with_the_same_text_limits() { + for name in ["", " ", "My account"] { + let request: StartAccountAuthorizationRequest = serde_json::from_value(json!({ + "provider": "openai", "name": name + })) + .expect("decode authorization"); + assert!(request.validate().is_ok()); + } + for name in ["bad\nname".to_owned(), "x".repeat(513)] { + let request: StartAccountAuthorizationRequest = serde_json::from_value(json!({ + "provider": "openai", "name": name + })) + .expect("decode authorization"); + assert_eq!(request.validate().unwrap_err().field(), "name"); + } + } + #[test] fn credential_recovery_requests_should_not_accept_client_revision_fences() { let authorization: StartAccountAuthorizationRequest = serde_json::from_value(json!({ diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs index aa40b2d9f..51a86b9ee 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs @@ -146,7 +146,6 @@ impl NewProviderAccount { pub fn validate(&self) -> StoreResult<()> { require_nonempty(ENTITY, "id", &self.id)?; require_nonempty(ENTITY, "provider_kind", &self.provider_kind)?; - require_nonempty(ENTITY, "name", &self.name)?; if let Some(upstream_user_id) = &self.upstream_user_id { require_nonempty(ENTITY, "upstream_user_id", upstream_user_id)?; } diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts/authorization.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts/authorization.rs index 9963755b2..65e25a6f0 100644 --- a/backend/crates/gateway-store/tests/postgres/provider_accounts/authorization.rs +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts/authorization.rs @@ -9,6 +9,36 @@ use super::{ *, }; +#[tokio::test] +async fn authorization_persists_an_unnamed_account_with_its_email() { + let Some(database) = TestDatabase::create("authorization_unnamed").await else { + return; + }; + let store = admin_account_store(&database.pool); + let mut prepared = credential("acct_unnamed", "unnamed-user"); + prepared.name.clear(); + prepared.email = Some("identity@example.com".into()); + let (authorization, context) = command(prepared); + let result = store + .commit_authorization(authorization, &context) + .await + .unwrap(); + let account = store + .load_account( + result.result.account_id.as_str(), + AccountRuntimeSnapshot::default(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(account.account.name, ""); + assert_eq!( + account.account.email.as_deref(), + Some("identity@example.com") + ); + database.close().await; +} + #[tokio::test] async fn concurrent_authorization_retries_share_one_account_write_and_one_receipt() { let Some(database) = TestDatabase::create("authorization_receipt_race").await else { diff --git a/backend/crates/providers/openai/src/credential/admin.rs b/backend/crates/providers/openai/src/credential/admin.rs index 6d6815316..c614e9912 100644 --- a/backend/crates/providers/openai/src/credential/admin.rs +++ b/backend/crates/providers/openai/src/credential/admin.rs @@ -602,9 +602,6 @@ impl CodexCredentialAdmin { .map_err(|_| CodexCredentialAdminError::InvalidInput)?; let provider = ProviderKind::new(PROVIDER_NAME) .map_err(|_| CodexCredentialAdminError::InvalidInput)?; - if input.name.trim().is_empty() { - return Err(CodexCredentialAdminError::InvalidInput); - } let revision = CredentialRevision::new(1).map_err(|_| CodexCredentialAdminError::InvalidCredential)?; let CodexOAuthMetadata { diff --git a/backend/crates/providers/openai/src/credential/oauth.rs b/backend/crates/providers/openai/src/credential/oauth.rs index fa4236a0a..0435fc120 100644 --- a/backend/crates/providers/openai/src/credential/oauth.rs +++ b/backend/crates/providers/openai/src/credential/oauth.rs @@ -184,8 +184,7 @@ impl CodexPendingAuthorization { if !valid_text(&pending.flow_id) || !valid_text(&pending.owner_ref) || !valid_text(&pending.started_request_ref) - || !(valid_text(&pending.name) - || pending.name.is_empty() && pending.reauthorization.is_some()) + || !(valid_text(&pending.name) || pending.name.is_empty()) || pending.expires_at <= Utc::now() || !valid_secret(pending.state.expose_secret()) || !valid_secret(pending.nonce.expose_secret()) @@ -590,7 +589,7 @@ impl CodexOAuthAdminService { reauthorization: Option, installation_id: String, ) -> Result { - if !(valid_text(&name) || name.is_empty() && reauthorization.is_some()) { + if !(valid_text(&name) || name.is_empty()) { return Err(CodexOAuthAdminError::InvalidInput); } let expires_at = Utc::now() diff --git a/backend/crates/providers/openai/tests/credential/oauth.rs b/backend/crates/providers/openai/tests/credential/oauth.rs index 920012fc4..1686376ed 100644 --- a/backend/crates/providers/openai/tests/credential/oauth.rs +++ b/backend/crates/providers/openai/tests/credential/oauth.rs @@ -156,6 +156,13 @@ fn id_token(payload: serde_json::Value) -> String { async fn complete( id_token: String, +) -> Result<(CompletedCodexOAuthCredential, String, String), CodexOAuthAdminError> { + complete_with_name(id_token, "new OAuth account").await +} + +async fn complete_with_name( + id_token: String, + name: &str, ) -> Result<(CompletedCodexOAuthCredential, String, String), CodexOAuthAdminError> { let pending_store = Arc::new(PendingStore::default()); let service = CodexOAuthAdminService::new( @@ -167,7 +174,13 @@ async fn complete( ); let started = service .start_authorization(StartCodexOAuthAuthorization { - mutation: mutation(), + mutation: PendingAuthorizationMutation::new( + ProviderKind::new("openai").expect("provider"), + AuthorizationMutationTarget::Create { + name: name.to_owned(), + }, + mutation().owner_binding().clone(), + ), }) .await .expect("start OAuth authorization"); @@ -463,6 +476,23 @@ async fn repeated_reauthorization_preserves_surface_id_with_a_cleared_name() { assert_eq!(surface_ids[0], surface_ids[1]); } +#[tokio::test] +async fn first_exchange_preserves_optional_local_name_and_upstream_email() { + for name in ["", "My account"] { + let (credential, _, _) = complete_with_name( + id_token(serde_json::json!({ "email": "identity@example.com" })), + name, + ) + .await + .expect("complete authorization with optional name"); + let CompletedCodexOAuthCredential::Create(account) = credential else { + panic!("expected account creation"); + }; + assert_eq!(account.account.name(), name); + assert_eq!(account.account.email(), Some("identity@example.com")); + } +} + #[tokio::test] async fn first_exchange_persists_the_installation_id_without_reusing_it_as_the_surface_id() { let (credential, pending_installation_id, surface_stable_id) = complete(id_token( diff --git a/backend/crates/providers/xai/src/credential/repository.rs b/backend/crates/providers/xai/src/credential/repository.rs index fa2b2c517..e16915905 100644 --- a/backend/crates/providers/xai/src/credential/repository.rs +++ b/backend/crates/providers/xai/src/credential/repository.rs @@ -843,7 +843,7 @@ fn validate_scope(scope: &str) -> Result<(), GrokCredentialRepositoryError> { } fn validate_name(value: &str) -> Result<(), GrokCredentialRepositoryError> { - if value.trim().is_empty() + if (!value.is_empty() && value.trim().is_empty()) || value.len() > MAX_NAME_BYTES || value.chars().any(char::is_control) { diff --git a/backend/crates/providers/xai/tests/credential/repository.rs b/backend/crates/providers/xai/tests/credential/repository.rs index ecf740dd3..0e897ada4 100644 --- a/backend/crates/providers/xai/tests/credential/repository.rs +++ b/backend/crates/providers/xai/tests/credential/repository.rs @@ -18,6 +18,18 @@ fn repository_rejects_identity_that_cannot_be_sent_as_official_header() { )); } +#[tokio::test] +async fn create_should_allow_an_empty_local_name() { + let store = MemoryProviderAccountStore::shared(); + let mut input = create_input("unnamed", "subject-unnamed"); + input.name.clear(); + seed_input(&store, &input) + .await + .expect("seed unnamed account"); + let account = store.get_account(&input.account_id).await.unwrap().unwrap(); + assert_eq!(account.name(), ""); +} + #[tokio::test] async fn lifecycle_projection_should_return_expiry_without_oauth_secrets() { let store = MemoryProviderAccountStore::shared(); diff --git a/backend/migrations/.frozen-sha256 b/backend/migrations/.frozen-sha256 index 83b8509a2..bf3c6f75e 100644 --- a/backend/migrations/.frozen-sha256 +++ b/backend/migrations/.frozen-sha256 @@ -33,3 +33,4 @@ a0e409402611dbc76bb71393b8eda7c717756c18974dbabca305fdcb4b115f31 0032_usage_and 592b80972af3bd8b3527b42da3703ca1106ecaf8fd5273c676189ca28fcbd067 0033_proxy_and_reset_credits.sql 9c15a6b6c1f9eab8ad478772f98f8fc4a997b825d58d12cffe1a15579c5785b3 0034_account_error_protection.sql bd73379456839e8a12d7591ad2a839901af0f5ba27871248569abdfa26bc83ef 0035_cpr_link_and_concurrency_defaults.sql +d05442ee4cb85db8131e015dbed9c6ad988f93306abcda34647317674857ee44 0036_account_display_names.sql diff --git a/backend/migrations/0036_account_display_names.sql b/backend/migrations/0036_account_display_names.sql new file mode 100644 index 000000000..27fb8c2d3 --- /dev/null +++ b/backend/migrations/0036_account_display_names.sql @@ -0,0 +1,27 @@ +-- 默认授权名称不代表用户命名,清空后沿用邮箱展示 +-- 已有改名审计或没有邮箱的账号保留原值 +with migrated as ( + update provider_accounts as account + set name = '', updated_at = greatest(now(), account.updated_at) + where account.authentication_kind = 'oauth' + and nullif(btrim(account.email), '') is not null + and ( + (account.provider_kind = 'openai' and account.name in ( + 'openai OAuth', 'OpenAI 账号', 'OpenAI 賬號', 'OpenAI account', + 'Codex 账号', 'Codex 賬號', 'Codex account' + )) + or (account.provider_kind = 'xai' and account.name in ( + 'xai OAuth', 'xAI 账号', 'xAI 賬號', 'xAI account' + )) + ) + and not exists ( + select 1 from admin_audit_events as audit + where audit.entity_kind = 'provider_account' + and audit.entity_ref = account.id + and 'name' = any(audit.changed_fields) + ) + returning account.id +) +update runtime_settings +set config_revision = config_revision + 1, updated_at = greatest(now(), updated_at) +where exists (select 1 from migrated); diff --git a/deploy/README.md b/deploy/README.md index b8c4c352f..bdb4d9adf 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -665,6 +665,12 @@ CPR_BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ docker compose -f deploy/compose.yaml build codex-proxy-rs ``` +### 账号名称迁移 + +OAuth 账号仍使用默认授权名称(如「Codex 账号」「OpenAI 账号」「xAI 账号」)且保存了邮箱时, +升级会清空默认名称,账号列表按邮箱前缀和完整邮箱分两行展示。 +自定义名称、已有改名审计记录的账号、无邮箱账号及 API Key 名称保留原值 + ### 错误保护配置升级 使用 `accountAutoFreeze*` 运行参数的部署升级时,数据库迁移删除全部旧保护设置,不转换规则或继承参数。 diff --git a/docs/api.md b/docs/api.md index 49fd4ea59..c97598e10 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1403,6 +1403,9 @@ OAuth start 使用: 客户端不得提交 `credentialRevision`、旧 token 身份或其他并发控制字段。complete 请求也不重复提交 `accountId`,后端通过 `flowId` 中保存的目标绑定完成授权 +`name` 可为空字符串,表示不设置本地名称;管理端新建授权默认留空,列表使用上游邮箱展示。 +提供名称时去除首尾空白,长度与字符限制沿用账号名称规则 + 流程只允许发起它的管理员身份继续;尚未完成的流程过期后需重新发起。 账号提交成功后,`complete` 保留 24 小时的完成结果。同一管理员使用相同 `provider`、`flowId` 重试时返回原账号结果,不再执行授权或更新凭据;新提交的 `callbackUrl` 和 `settings` 不会再次应用 diff --git a/frontend/src/i18n/locales/en/accounts.json b/frontend/src/i18n/locales/en/accounts.json index 03d1cfae6..08cb490eb 100644 --- a/frontend/src/i18n/locales/en/accounts.json +++ b/frontend/src/i18n/locales/en/accounts.json @@ -355,7 +355,6 @@ "importTasksCreated": "Import tasks created: {submitted}", "importTaskCreated": "Import task created", "partialSubmission": "Some import tasks were created, retry with the same input to submit the rest", - "accountName": "{provider} account", "failedToStartAuthorization": "Failed to start authorization" }, "importTasks": { diff --git a/frontend/src/i18n/locales/zh-Hans/accounts.json b/frontend/src/i18n/locales/zh-Hans/accounts.json index 4744bfbc8..12a3b7f36 100644 --- a/frontend/src/i18n/locales/zh-Hans/accounts.json +++ b/frontend/src/i18n/locales/zh-Hans/accounts.json @@ -355,7 +355,6 @@ "importTasksCreated": "已创建 {submitted} 个导入任务", "importTaskCreated": "导入任务已创建", "partialSubmission": "部分导入任务已创建,保持输入不变重试可继续提交", - "accountName": "{provider} 账号", "failedToStartAuthorization": "启动授权失败" }, "importTasks": { diff --git a/frontend/src/i18n/locales/zh-Hant/accounts.json b/frontend/src/i18n/locales/zh-Hant/accounts.json index ae73cd2f2..af8b47279 100644 --- a/frontend/src/i18n/locales/zh-Hant/accounts.json +++ b/frontend/src/i18n/locales/zh-Hant/accounts.json @@ -355,7 +355,6 @@ "importTasksCreated": "已創建 {submitted} 個導入任務", "importTaskCreated": "導入任務已創建", "partialSubmission": "部分導入任務已創建,保持輸入不變重試可繼續提交", - "accountName": "{provider} 賬號", "failedToStartAuthorization": "啓動授權失敗" }, "importTasks": { diff --git a/frontend/src/views/accounts/components/AccountIdentityCell.vue b/frontend/src/views/accounts/components/AccountIdentityCell.vue index c2724d9b8..eea70894c 100644 --- a/frontend/src/views/accounts/components/AccountIdentityCell.vue +++ b/frontend/src/views/accounts/components/AccountIdentityCell.vue @@ -29,17 +29,31 @@ const props = withDefaults( }, ) -const displayTitle = computed(() => accountIdentityLabel(props.account)) +const emailText = computed(() => props.account.email?.trim()) +const displayTitle = computed(() => { + const label = accountIdentityLabel(props.account) + return props.account.authenticationKind !== 'api_key' && label === emailText.value + ? label.split('@')[0] + : label +}) const visibleNotes = computed(() => props.showNotes ? props.account.notes : undefined) const identityTitle = computed(() => { - const email = props.account.email?.trim() + const email = emailText.value return email && email !== displayTitle.value ? `${displayTitle.value}\n${email}` : displayTitle.value }) +const secondaryText = computed(() => props.account.authenticationKind !== 'api_key' && emailText.value !== displayTitle.value + ? emailText.value + : undefined) + +const secondaryClass = computed(() => props.size === 'lg' + ? 'mt-1 text-z-sm text-z-text-secondary' + : 'mt-0.5 font-mono text-z-xs text-z-text-quaternary') + const initial = computed(() => displayTitle.value.slice(0, 1).toUpperCase()) const avatarSizeClass = computed(() => @@ -89,6 +103,9 @@ const avatarToneClass = computed(() => { +
+ {{ secondaryText }} +
diff --git a/frontend/src/views/accounts/components/account-quota-summary-cell/index.vue b/frontend/src/views/accounts/components/account-quota-summary-cell/index.vue index 2fda0311e..7592bc50b 100644 --- a/frontend/src/views/accounts/components/account-quota-summary-cell/index.vue +++ b/frontend/src/views/accounts/components/account-quota-summary-cell/index.vue @@ -18,6 +18,8 @@ const emit = defineEmits<{ showConcurrency: [] }>() const { t } = useI18n({ useScope: 'global' }) +const localUsageOnly = computed(() => props.account.authenticationKind === 'api_key' && !props.account.upstream?.discovered) + // 互联列表只展示此 Key 的消费限额,池内各账号的百分比不能代表整个上游 const quotaWindows = computed(() => props.account.upstream?.discovered ? props.account.quota.windows.filter(window => window.amount) @@ -57,11 +59,11 @@ const additionalEntryCount = computed(() => Math.max(summaryEntries.value.length