mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-15 00:06:24 +00:00
feat: refine account lifecycle and login page
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -26,7 +26,9 @@ use crate::{
|
||||
},
|
||||
admin::{
|
||||
accounts::service::{
|
||||
AdminAccountError, AdminAccountMetadata, AdminAccountUpdate, OAuthExchangeInput,
|
||||
AdminAccountError, AdminAccountHealthCheck, AdminAccountMetadata,
|
||||
AdminAccountRefreshOutcome, AdminAccountRefreshResult, AdminAccountUpdate,
|
||||
OAuthExchangeInput,
|
||||
},
|
||||
monitoring::{billing, service::AdminUsageRecord},
|
||||
},
|
||||
@@ -405,6 +407,73 @@ enum AccountUpdateData {
|
||||
Account(Box<AdminAccountData>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AccountRefreshData {
|
||||
id: String,
|
||||
email: Option<String>,
|
||||
previous_status: String,
|
||||
result: &'static str,
|
||||
error: Option<String>,
|
||||
duration_ms: i64,
|
||||
}
|
||||
|
||||
impl From<AdminAccountRefreshResult> for AccountRefreshData {
|
||||
fn from(result: AdminAccountRefreshResult) -> Self {
|
||||
Self {
|
||||
id: result.id,
|
||||
email: result.email,
|
||||
previous_status: account_status_str(result.previous_status).to_string(),
|
||||
result: account_refresh_outcome_str(result.outcome),
|
||||
error: result.error,
|
||||
duration_ms: result.duration_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub(crate) struct AccountHealthCheckRequest {
|
||||
ids: Option<Vec<String>>,
|
||||
stagger_ms: Option<u64>,
|
||||
concurrency: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AccountHealthCheckSummaryData {
|
||||
total: usize,
|
||||
alive: usize,
|
||||
dead: usize,
|
||||
skipped: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AccountHealthCheckData {
|
||||
summary: AccountHealthCheckSummaryData,
|
||||
results: Vec<AccountRefreshData>,
|
||||
}
|
||||
|
||||
impl From<AdminAccountHealthCheck> for AccountHealthCheckData {
|
||||
fn from(result: AdminAccountHealthCheck) -> Self {
|
||||
let summary = AccountHealthCheckSummaryData {
|
||||
total: result.results.len(),
|
||||
alive: result.alive(),
|
||||
dead: result.dead(),
|
||||
skipped: result.skipped(),
|
||||
};
|
||||
Self {
|
||||
summary,
|
||||
results: result
|
||||
.results
|
||||
.into_iter()
|
||||
.map(AccountRefreshData::from)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AccountImportData {
|
||||
@@ -541,9 +610,43 @@ pub(crate) async fn refresh_account(
|
||||
.refresh_account(&account_id)
|
||||
.await
|
||||
{
|
||||
Ok(account) => Ok(AdminResponse::new(
|
||||
Ok(result) => Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AdminAccountData::from(account)),
|
||||
AdminEnvelope::ok(AccountRefreshData::from(result)),
|
||||
)),
|
||||
Err(error) => Err(account_error(&error)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/admin/accounts/health-check`
|
||||
pub(crate) async fn health_check_accounts(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<AccountHealthCheckRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
require_admin_auth(&state, &headers).await?;
|
||||
let stagger_ms = payload.stagger_ms.unwrap_or(3_000);
|
||||
if !(500..=30_000).contains(&stagger_ms) && stagger_ms != 0 {
|
||||
return Err(AdminError::bad_request(
|
||||
"stagger_ms must be between 500 and 30000",
|
||||
));
|
||||
}
|
||||
if let Some(concurrency) = payload.concurrency {
|
||||
if !(1..=10).contains(&concurrency) {
|
||||
return Err(AdminError::bad_request(
|
||||
"concurrency must be between 1 and 10",
|
||||
));
|
||||
}
|
||||
}
|
||||
match state
|
||||
.services
|
||||
.admin_accounts
|
||||
.health_check_accounts(payload.ids, stagger_ms, payload.concurrency.unwrap_or(2))
|
||||
.await
|
||||
{
|
||||
Ok(result) => Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AccountHealthCheckData::from(result)),
|
||||
)),
|
||||
Err(error) => Err(account_error(&error)),
|
||||
}
|
||||
@@ -1464,6 +1567,14 @@ fn account_error(error: &AdminAccountError) -> AdminError {
|
||||
}
|
||||
}
|
||||
|
||||
fn account_refresh_outcome_str(outcome: AdminAccountRefreshOutcome) -> &'static str {
|
||||
match outcome {
|
||||
AdminAccountRefreshOutcome::Alive => "alive",
|
||||
AdminAccountRefreshOutcome::Dead => "dead",
|
||||
AdminAccountRefreshOutcome::Skipped => "skipped",
|
||||
}
|
||||
}
|
||||
|
||||
fn account_not_found() -> AdminError {
|
||||
AdminError::not_found("Account not found")
|
||||
}
|
||||
|
||||
@@ -92,8 +92,6 @@ impl AdminAccountService {
|
||||
.as_ref()
|
||||
.map(|claims| claims.expires_at)
|
||||
.or(access_token_expires_at);
|
||||
let next_refresh_at = access_token_expires_at
|
||||
.map(|expires_at| self.next_refresh_at_for_expires_at(expires_at));
|
||||
let claims = resolved_tokens.claims.as_ref();
|
||||
let mut plan_type = claims
|
||||
.and_then(|claims| claims.plan_type.clone())
|
||||
@@ -145,6 +143,8 @@ impl AdminAccountService {
|
||||
chatgpt_user_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let next_refresh_at = access_token_expires_at
|
||||
.map(|expires_at| self.next_refresh_at_for_expires_at(&account_id, expires_at));
|
||||
if plan_type.is_none() {
|
||||
plan_type = supplemental.plan_type;
|
||||
}
|
||||
@@ -297,7 +297,7 @@ impl AdminAccountService {
|
||||
),
|
||||
))
|
||||
.ok_or(AdminAccountError::TokenRequired)?;
|
||||
refresh_token = refreshed.refresh_token.or(Some(existing_refresh_token));
|
||||
refresh_token = refreshed.refresh_token;
|
||||
let claims = crate::upstream::accounts::token_refresh::manual_account_claims(
|
||||
&access_token,
|
||||
Utc::now(),
|
||||
@@ -337,7 +337,7 @@ impl AdminAccountService {
|
||||
crate::upstream::accounts::importing::normalize_bearer_token(&refreshed.access_token),
|
||||
))
|
||||
.ok_or(AdminAccountError::TokenRequired)?;
|
||||
refresh_token = refreshed.refresh_token.or(Some(existing_refresh_token));
|
||||
refresh_token = refreshed.refresh_token;
|
||||
let claims = crate::upstream::accounts::token_refresh::manual_account_claims(
|
||||
&access_token,
|
||||
Utc::now(),
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
use chrono::Utc;
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use std::{
|
||||
collections::{hash_map::DefaultHasher, HashSet},
|
||||
hash::{Hash, Hasher},
|
||||
};
|
||||
|
||||
use crate::infra::json::{NumberedPage, Page};
|
||||
use chrono::Utc;
|
||||
use futures::{stream, StreamExt};
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::{
|
||||
infra::json::{NumberedPage, Page},
|
||||
upstream::accounts::store::AccountStore,
|
||||
};
|
||||
|
||||
use super::{
|
||||
types::{
|
||||
parse_account_status, stored_to_admin_metadata, AdminAccountError, AdminAccountMetadata,
|
||||
parse_account_status, stored_to_admin_metadata, AdminAccountError, AdminAccountHealthCheck,
|
||||
AdminAccountMetadata, AdminAccountRefreshOutcome, AdminAccountRefreshResult,
|
||||
AdminAccountUpdate, BatchDeleteAccounts, ManualCreateTokens, UpdatedAccountStatus,
|
||||
},
|
||||
AdminAccountService,
|
||||
@@ -97,10 +108,7 @@ impl AdminAccountService {
|
||||
.ok_or(AdminAccountError::TokenRequired)?;
|
||||
ManualCreateTokens {
|
||||
access_token,
|
||||
refresh_token_for_new: token_pair
|
||||
.refresh_token
|
||||
.clone()
|
||||
.or_else(|| Some(refresh_token.clone())),
|
||||
refresh_token_for_new: token_pair.refresh_token.clone(),
|
||||
refresh_token_for_existing: token_pair.refresh_token,
|
||||
}
|
||||
} else {
|
||||
@@ -137,7 +145,7 @@ impl AdminAccountService {
|
||||
.map(|token| SecretString::new(token.into())),
|
||||
access_token_expires_at: Some(claims.expires_at),
|
||||
next_refresh_at: Some(
|
||||
self.next_refresh_at_for_expires_at(claims.expires_at),
|
||||
self.next_refresh_at_for_expires_at(&existing.id, claims.expires_at),
|
||||
),
|
||||
status: crate::upstream::accounts::model::AccountStatus::Active,
|
||||
},
|
||||
@@ -292,16 +300,34 @@ impl AdminAccountService {
|
||||
pub async fn refresh_account(
|
||||
&self,
|
||||
account_id: &str,
|
||||
) -> Result<AdminAccountMetadata, AdminAccountError> {
|
||||
) -> Result<AdminAccountRefreshResult, AdminAccountError> {
|
||||
let account = match self.store.get(account_id).await {
|
||||
Ok(Some(account)) => account,
|
||||
Ok(None) => return Err(AdminAccountError::NotFound),
|
||||
Err(_) => return Err(AdminAccountError::Inspect),
|
||||
};
|
||||
let Some(refresh_token) = account.refresh_token.as_ref() else {
|
||||
return Err(AdminAccountError::TokenRequired);
|
||||
let previous_status = account.status;
|
||||
let started_at = std::time::Instant::now();
|
||||
|
||||
let skipped = |error: &'static str| AdminAccountRefreshResult {
|
||||
id: account.id.clone(),
|
||||
email: account.email.clone(),
|
||||
previous_status,
|
||||
outcome: AdminAccountRefreshOutcome::Skipped,
|
||||
error: Some(error.to_string()),
|
||||
duration_ms: elapsed_millis_i64(started_at),
|
||||
};
|
||||
|
||||
let Some(refresh_token) = account.refresh_token.as_ref() else {
|
||||
return Ok(skipped("no refresh token"));
|
||||
};
|
||||
if account.status == crate::upstream::accounts::model::AccountStatus::Disabled {
|
||||
return Ok(skipped("manually disabled"));
|
||||
}
|
||||
if account.status == crate::upstream::accounts::model::AccountStatus::Refreshing {
|
||||
return Ok(skipped("refresh already in progress"));
|
||||
}
|
||||
|
||||
match self
|
||||
.token_refresher
|
||||
.refresh(refresh_token.expose_secret())
|
||||
@@ -334,7 +360,7 @@ impl AdminAccountService {
|
||||
.map(|token| SecretString::new(token.into())),
|
||||
access_token_expires_at: Some(claims.expires_at),
|
||||
next_refresh_at: Some(
|
||||
self.next_refresh_at_for_expires_at(claims.expires_at),
|
||||
self.next_refresh_at_for_expires_at(account_id, claims.expires_at),
|
||||
),
|
||||
status: crate::upstream::accounts::model::AccountStatus::Active,
|
||||
},
|
||||
@@ -345,11 +371,18 @@ impl AdminAccountService {
|
||||
return Err(AdminAccountError::NotFound);
|
||||
}
|
||||
self.sync_account_pool(account_id).await?;
|
||||
Ok(AdminAccountRefreshResult {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
previous_status,
|
||||
outcome: AdminAccountRefreshOutcome::Alive,
|
||||
error: None,
|
||||
duration_ms: elapsed_millis_i64(started_at),
|
||||
})
|
||||
}
|
||||
Err(failure) => {
|
||||
if let Some(status) =
|
||||
manual_refresh_failure_status(&failure, account.access_token.expose_secret())
|
||||
{
|
||||
if manual_refresh_failure_is_permanent(&failure) {
|
||||
let status = crate::upstream::accounts::model::AccountStatus::Expired;
|
||||
let updated = self
|
||||
.store
|
||||
.set_status(account_id, status)
|
||||
@@ -373,46 +406,85 @@ impl AdminAccountService {
|
||||
self.sync_account_pool_best_effort(account_id, "account refresh failure")
|
||||
.await;
|
||||
}
|
||||
return Err(AdminAccountError::RefreshTokenExchange(failure));
|
||||
Ok(AdminAccountRefreshResult {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
previous_status,
|
||||
outcome: AdminAccountRefreshOutcome::Dead,
|
||||
error: Some(format!("token refresh exchange failed: {failure}")),
|
||||
duration_ms: elapsed_millis_i64(started_at),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.store
|
||||
.get(account_id)
|
||||
pub async fn health_check_accounts(
|
||||
&self,
|
||||
ids: Option<Vec<String>>,
|
||||
stagger_ms: u64,
|
||||
concurrency: usize,
|
||||
) -> Result<AdminAccountHealthCheck, AdminAccountError> {
|
||||
let accounts = self
|
||||
.store
|
||||
.list_pool_accounts()
|
||||
.await
|
||||
.map_err(|_| AdminAccountError::Inspect)?
|
||||
.map(stored_to_admin_metadata)
|
||||
.ok_or(AdminAccountError::NotFound)
|
||||
.map_err(|_| AdminAccountError::List)?;
|
||||
let ids = ids.map(|ids| ids.into_iter().collect::<HashSet<_>>());
|
||||
let candidate_ids = accounts
|
||||
.into_iter()
|
||||
.filter(|account| {
|
||||
ids.as_ref()
|
||||
.is_none_or(|ids| ids.contains(account.id.as_str()))
|
||||
})
|
||||
.map(|account| account.id)
|
||||
.collect::<Vec<_>>();
|
||||
let results = stream::iter(candidate_ids.into_iter().enumerate().map(
|
||||
|(index, account_id)| async move {
|
||||
if index > 0 && stagger_ms > 0 {
|
||||
let base_delay = stagger_ms.saturating_mul(index.min(concurrency) as u64);
|
||||
let delay = stable_jittered_millis(&account_id, base_delay, 0.30);
|
||||
sleep(std::time::Duration::from_millis(delay)).await;
|
||||
}
|
||||
self.refresh_account(&account_id).await
|
||||
},
|
||||
))
|
||||
.buffer_unordered(concurrency.max(1))
|
||||
.collect::<Vec<_>>()
|
||||
.await
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(AdminAccountHealthCheck { results })
|
||||
}
|
||||
}
|
||||
|
||||
fn manual_refresh_failure_status(
|
||||
fn stable_jittered_millis(account_id: &str, base_millis: u64, variance: f64) -> u64 {
|
||||
if base_millis == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
account_id.hash(&mut hasher);
|
||||
"health-check-stagger".hash(&mut hasher);
|
||||
let unit = hasher.finish() as f64 / u64::MAX as f64;
|
||||
let factor = (1.0 - variance) + unit * variance * 2.0;
|
||||
(base_millis as f64 * factor)
|
||||
.round()
|
||||
.clamp(0.0, u64::MAX as f64) as u64
|
||||
}
|
||||
|
||||
fn manual_refresh_failure_is_permanent(
|
||||
failure: &crate::upstream::accounts::token_refresh::RefreshFailure,
|
||||
current_access_token: &str,
|
||||
) -> Option<crate::upstream::accounts::model::AccountStatus> {
|
||||
) -> bool {
|
||||
match failure {
|
||||
crate::upstream::accounts::token_refresh::RefreshFailure::QuotaExhausted => {
|
||||
Some(crate::upstream::accounts::model::AccountStatus::QuotaExhausted)
|
||||
}
|
||||
crate::upstream::accounts::token_refresh::RefreshFailure::Banned => {
|
||||
Some(crate::upstream::accounts::model::AccountStatus::Banned)
|
||||
}
|
||||
crate::upstream::accounts::token_refresh::RefreshFailure::Disabled => {
|
||||
Some(crate::upstream::accounts::model::AccountStatus::Disabled)
|
||||
}
|
||||
crate::upstream::accounts::token_refresh::RefreshFailure::InvalidGrant
|
||||
if matches!(
|
||||
crate::upstream::accounts::token_refresh::jwt_expiry(
|
||||
current_access_token,
|
||||
Utc::now()
|
||||
),
|
||||
crate::upstream::accounts::token_refresh::JwtExpiry::Expired
|
||||
) =>
|
||||
{
|
||||
Some(crate::upstream::accounts::model::AccountStatus::Expired)
|
||||
}
|
||||
crate::upstream::accounts::token_refresh::RefreshFailure::InvalidGrant
|
||||
| crate::upstream::accounts::token_refresh::RefreshFailure::Banned => true,
|
||||
crate::upstream::accounts::token_refresh::RefreshFailure::QuotaExhausted
|
||||
| crate::upstream::accounts::token_refresh::RefreshFailure::RetryableTransport
|
||||
| crate::upstream::accounts::token_refresh::RefreshFailure::Transport => None,
|
||||
| crate::upstream::accounts::token_refresh::RefreshFailure::Transport => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed_millis_i64(started_at: std::time::Instant) -> i64 {
|
||||
i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ mod types;
|
||||
|
||||
use std::sync::Arc as StdArc;
|
||||
|
||||
use chrono::{DateTime, Duration};
|
||||
use chrono::DateTime;
|
||||
|
||||
use crate::{
|
||||
upstream::accounts::{
|
||||
@@ -25,9 +25,9 @@ use crate::{
|
||||
};
|
||||
|
||||
pub use types::{
|
||||
AdminAccountError, AdminAccountMetadata, AdminAccountUpdate, BatchDeleteAccounts,
|
||||
ExportedAccounts, ImportedAccounts, OAuthAuthorizeResult, OAuthExchangeInput,
|
||||
UpdatedAccountStatus,
|
||||
AdminAccountError, AdminAccountHealthCheck, AdminAccountMetadata, AdminAccountRefreshOutcome,
|
||||
AdminAccountRefreshResult, AdminAccountUpdate, BatchDeleteAccounts, ExportedAccounts,
|
||||
ImportedAccounts, OAuthAuthorizeResult, OAuthExchangeInput, UpdatedAccountStatus,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -72,13 +72,14 @@ impl AdminAccountService {
|
||||
|
||||
pub(crate) fn next_refresh_at_for_expires_at(
|
||||
&self,
|
||||
account_id: &str,
|
||||
expires_at: DateTime<chrono::Utc>,
|
||||
) -> DateTime<chrono::Utc> {
|
||||
let margin_seconds = self
|
||||
.refresh_policy
|
||||
.refresh_margin_seconds()
|
||||
.min(i64::MAX as u64) as i64;
|
||||
expires_at - Duration::seconds(margin_seconds)
|
||||
crate::upstream::accounts::token_refresh::jittered_refresh_at(
|
||||
account_id,
|
||||
expires_at,
|
||||
self.refresh_policy.refresh_margin_seconds(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn sync_account_pool(
|
||||
|
||||
@@ -117,6 +117,51 @@ pub struct UpdatedAccountStatus {
|
||||
pub status: AcctStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AdminAccountRefreshOutcome {
|
||||
Alive,
|
||||
Dead,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AdminAccountRefreshResult {
|
||||
pub id: String,
|
||||
pub email: Option<String>,
|
||||
pub previous_status: AcctStatus,
|
||||
pub outcome: AdminAccountRefreshOutcome,
|
||||
pub error: Option<String>,
|
||||
pub duration_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AdminAccountHealthCheck {
|
||||
pub results: Vec<AdminAccountRefreshResult>,
|
||||
}
|
||||
|
||||
impl AdminAccountHealthCheck {
|
||||
pub fn alive(&self) -> usize {
|
||||
self.results
|
||||
.iter()
|
||||
.filter(|result| result.outcome == AdminAccountRefreshOutcome::Alive)
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn dead(&self) -> usize {
|
||||
self.results
|
||||
.iter()
|
||||
.filter(|result| result.outcome == AdminAccountRefreshOutcome::Dead)
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn skipped(&self) -> usize {
|
||||
self.results
|
||||
.iter()
|
||||
.filter(|result| result.outcome == AdminAccountRefreshOutcome::Skipped)
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AdminAccountUpdate {
|
||||
pub label: Option<Option<String>>,
|
||||
@@ -280,6 +325,11 @@ pub(super) fn import_status_from_usage_error(error: &CodexClientError) -> Option
|
||||
}
|
||||
|
||||
pub(super) fn parse_account_status(status: &str) -> Result<AcctStatus, AdminAccountError> {
|
||||
crate::upstream::accounts::importing::parse_account_status(status)
|
||||
.map_err(|_| AdminAccountError::InvalidStatus(status.trim().to_ascii_lowercase()))
|
||||
match status.trim().to_ascii_lowercase().as_str() {
|
||||
"active" => Ok(AcctStatus::Active),
|
||||
"disabled" => Ok(AcctStatus::Disabled),
|
||||
_ => Err(AdminAccountError::InvalidStatus(
|
||||
status.trim().to_ascii_lowercase(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -10,8 +10,8 @@ use crate::runtime::state::AppState;
|
||||
use super::{
|
||||
accounts::routes::{
|
||||
account_models, account_quota, accounts, batch_delete_accounts, create_account,
|
||||
export_accounts, import_accounts, oauth_authorize_account, oauth_exchange_account,
|
||||
refresh_account, test_account_connection, update_account,
|
||||
export_accounts, health_check_accounts, import_accounts, oauth_authorize_account,
|
||||
oauth_exchange_account, refresh_account, test_account_connection, update_account,
|
||||
},
|
||||
auth::session::{login, logout, session_status},
|
||||
keys::routes::{api_keys, batch_delete_api_keys, create_api_key, update_api_key},
|
||||
@@ -62,6 +62,10 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/api/admin/accounts/delete", post(batch_delete_accounts))
|
||||
.route("/api/admin/accounts/update", post(update_account))
|
||||
.route("/api/admin/accounts/refresh", post(refresh_account))
|
||||
.route(
|
||||
"/api/admin/accounts/health-check",
|
||||
post(health_check_accounts),
|
||||
)
|
||||
.route("/api/admin/accounts/quota", get(account_quota))
|
||||
.route("/api/admin/usage/records", get(usage_records))
|
||||
.route(
|
||||
|
||||
@@ -34,10 +34,12 @@ use crate::{
|
||||
upstream::accounts::{
|
||||
model::AccountStatus,
|
||||
pool::{AccountAcquireRequest, RuntimeAccountPoolService},
|
||||
token_refresh::RuntimeTokenRefreshService,
|
||||
},
|
||||
upstream::{
|
||||
models::ModelCatalog,
|
||||
protocol::responses::{apply_response_model_options, CodexResponsesRequest},
|
||||
token_client::OpenAiTokenClient,
|
||||
transport::{
|
||||
backend_transport_for_response_request, is_banned_upstream_error, CodexBackendClient,
|
||||
CodexClientError,
|
||||
@@ -52,6 +54,7 @@ pub struct ChatDispatchService {
|
||||
models: Arc<crate::upstream::models::ModelService>,
|
||||
codex: Arc<CodexBackendClient>,
|
||||
usage_records: Arc<AdminUsageRecordService>,
|
||||
token_refresh: Arc<RuntimeTokenRefreshService<OpenAiTokenClient>>,
|
||||
installation_id: Option<String>,
|
||||
cloudflare: CloudflareRecovery,
|
||||
}
|
||||
@@ -62,6 +65,7 @@ impl ChatDispatchService {
|
||||
models: Arc<crate::upstream::models::ModelService>,
|
||||
codex: Arc<CodexBackendClient>,
|
||||
usage_records: Arc<AdminUsageRecordService>,
|
||||
token_refresh: Arc<RuntimeTokenRefreshService<OpenAiTokenClient>>,
|
||||
installation_id: Option<String>,
|
||||
cloudflare: CloudflareRecovery,
|
||||
) -> Self {
|
||||
@@ -70,11 +74,30 @@ impl ChatDispatchService {
|
||||
models,
|
||||
codex,
|
||||
usage_records,
|
||||
token_refresh,
|
||||
installation_id,
|
||||
cloudflare,
|
||||
}
|
||||
}
|
||||
|
||||
fn trigger_refresh_after_auth_failure(&self, account_id: &str, status: AccountStatus) {
|
||||
if status != AccountStatus::Expired {
|
||||
return;
|
||||
}
|
||||
|
||||
let token_refresh = self.token_refresh.clone();
|
||||
let account_id = account_id.to_string();
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = token_refresh.trigger_account_refresh_now(&account_id).await {
|
||||
tracing::warn!(
|
||||
account_id = %account_id,
|
||||
error = %error,
|
||||
"reactive token refresh after upstream auth failure failed"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 调度非流式 Chat Completions 请求到 Codex Responses 上游。
|
||||
pub async fn complete(
|
||||
&self,
|
||||
@@ -288,6 +311,7 @@ impl ChatDispatchService {
|
||||
self.account_pool
|
||||
.set_status(&release_account_id, account_status)
|
||||
.await;
|
||||
self.trigger_refresh_after_auth_failure(&release_account_id, account_status);
|
||||
excluded_account_ids.push(release_account_id);
|
||||
}
|
||||
Err(error) if is_cloudflare_challenge_upstream_error(&error) => {
|
||||
|
||||
@@ -56,6 +56,7 @@ use crate::{
|
||||
upstream::accounts::{
|
||||
model::{Account, AccountStatus},
|
||||
pool::{AccountAcquireRequest, RuntimeAccountPoolService},
|
||||
token_refresh::RuntimeTokenRefreshService,
|
||||
},
|
||||
upstream::{
|
||||
models::ModelCatalog,
|
||||
@@ -71,6 +72,7 @@ use crate::{
|
||||
encode_sse_event, parse_sse_events, sse_body_has_done, SseError, DONE_SSE_FRAME,
|
||||
},
|
||||
},
|
||||
token_client::OpenAiTokenClient,
|
||||
transport::{
|
||||
backend_transport_for_response_request, is_banned_auth_signal,
|
||||
is_banned_upstream_error, CodexBackendClient, CodexBackendResponse,
|
||||
@@ -107,6 +109,7 @@ pub struct ResponseDispatchService {
|
||||
session_affinity: Arc<RuntimeSessionAffinityService>,
|
||||
reasoning_replay: Arc<Mutex<ReasoningReplayCache>>,
|
||||
usage_records: Arc<AdminUsageRecordService>,
|
||||
token_refresh: Arc<RuntimeTokenRefreshService<OpenAiTokenClient>>,
|
||||
installation_id: Option<String>,
|
||||
cloudflare: CloudflareRecovery,
|
||||
}
|
||||
@@ -154,6 +157,7 @@ impl ResponseDispatchService {
|
||||
codex: Arc<CodexBackendClient>,
|
||||
session_affinity: Arc<RuntimeSessionAffinityService>,
|
||||
usage_records: Arc<AdminUsageRecordService>,
|
||||
token_refresh: Arc<RuntimeTokenRefreshService<OpenAiTokenClient>>,
|
||||
installation_id: Option<String>,
|
||||
cloudflare: CloudflareRecovery,
|
||||
) -> Self {
|
||||
@@ -166,11 +170,30 @@ impl ResponseDispatchService {
|
||||
DEFAULT_REASONING_REPLAY_TTL_SECS,
|
||||
)))),
|
||||
usage_records,
|
||||
token_refresh,
|
||||
installation_id,
|
||||
cloudflare,
|
||||
}
|
||||
}
|
||||
|
||||
fn trigger_refresh_after_auth_failure(&self, account_id: &str, status: AccountStatus) {
|
||||
if status != AccountStatus::Expired {
|
||||
return;
|
||||
}
|
||||
|
||||
let token_refresh = self.token_refresh.clone();
|
||||
let account_id = account_id.to_string();
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = token_refresh.trigger_account_refresh_now(&account_id).await {
|
||||
tracing::warn!(
|
||||
account_id = %account_id,
|
||||
error = %error,
|
||||
"reactive token refresh after upstream auth failure failed"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn prepare_response_session(
|
||||
&self,
|
||||
request: &mut CodexResponsesRequest,
|
||||
@@ -711,6 +734,10 @@ impl ResponseDispatchService {
|
||||
self.account_pool
|
||||
.set_status(&release_account_id, account_status)
|
||||
.await;
|
||||
self.trigger_refresh_after_auth_failure(
|
||||
&release_account_id,
|
||||
account_status,
|
||||
);
|
||||
excluded_account_ids.push(release_account_id);
|
||||
continue;
|
||||
}
|
||||
@@ -771,6 +798,7 @@ impl ResponseDispatchService {
|
||||
self.account_pool
|
||||
.set_status(&release_account_id, account_status)
|
||||
.await;
|
||||
self.trigger_refresh_after_auth_failure(&release_account_id, account_status);
|
||||
excluded_account_ids.push(release_account_id);
|
||||
}
|
||||
Err(error) if is_cloudflare_challenge_upstream_error(&error) => {
|
||||
@@ -1338,6 +1366,10 @@ impl ResponseDispatchService {
|
||||
self.account_pool
|
||||
.set_status(&release_account_id, account_status)
|
||||
.await;
|
||||
self.trigger_refresh_after_auth_failure(
|
||||
&release_account_id,
|
||||
account_status,
|
||||
);
|
||||
excluded_account_ids.push(release_account_id);
|
||||
self.account_pool.release(&account.id).await;
|
||||
continue;
|
||||
@@ -1447,6 +1479,7 @@ impl ResponseDispatchService {
|
||||
self.account_pool
|
||||
.set_status(&release_account_id, account_status)
|
||||
.await;
|
||||
self.trigger_refresh_after_auth_failure(&release_account_id, account_status);
|
||||
excluded_account_ids.push(release_account_id);
|
||||
}
|
||||
Err(error) if is_cloudflare_challenge_upstream_error(&error) => {
|
||||
@@ -1837,6 +1870,7 @@ impl ResponseDispatchService {
|
||||
self.account_pool
|
||||
.set_status(&release_account_id, account_status)
|
||||
.await;
|
||||
self.trigger_refresh_after_auth_failure(&release_account_id, account_status);
|
||||
excluded_account_ids.push(release_account_id);
|
||||
}
|
||||
Err(error) if is_cloudflare_challenge_upstream_error(&error) => {
|
||||
|
||||
+16
-3
@@ -34,12 +34,13 @@ use crate::{
|
||||
store::{AccountStore as AccountStoreTrait, SqliteAccountStore},
|
||||
token_refresh::{
|
||||
RefreshLeaseStore as SqliteRefreshLeaseStore, RefreshPolicy, RuntimeRefreshPolicy,
|
||||
RuntimeTokenRefreshService,
|
||||
},
|
||||
},
|
||||
upstream::{
|
||||
fingerprint::{Fingerprint, FingerprintRepository},
|
||||
models::{ModelService, ModelServiceError, ModelSnapshotStore},
|
||||
token_client::{default_openai_token_client, TokenClientConfig},
|
||||
token_client::{default_openai_token_client, OpenAiTokenClient, TokenClientConfig},
|
||||
transport::{
|
||||
build_reqwest_client, tls::CustomCaError, CodexBackendClient, CodexModelCatalogClient,
|
||||
},
|
||||
@@ -90,6 +91,7 @@ pub struct Services {
|
||||
pub usage_records: StdArc<AdminUsageRecordService>,
|
||||
pub usage: StdArc<AdminUsageService>,
|
||||
pub account_pool: StdArc<RuntimeAccountPoolService>,
|
||||
pub token_refresh: StdArc<RuntimeTokenRefreshService<OpenAiTokenClient>>,
|
||||
pub chat: StdArc<ChatDispatchService>,
|
||||
pub responses: StdArc<ResponseDispatchService>,
|
||||
pub session_affinity: StdArc<RuntimeSessionAffinityService>,
|
||||
@@ -204,7 +206,15 @@ impl Services {
|
||||
runtime_client_keys.clone(),
|
||||
));
|
||||
let client_keys = StdArc::new(ClientKeyService::new(runtime_client_keys));
|
||||
let token_client = StdArc::new(default_openai_token_client(token_client_config(config)));
|
||||
let token_client = default_openai_token_client(token_client_config(config));
|
||||
let token_refresh = StdArc::new(
|
||||
RuntimeTokenRefreshService::new(
|
||||
stores.accounts.clone(),
|
||||
refresh_policy.clone(),
|
||||
token_client.clone(),
|
||||
)
|
||||
.with_refresh_lease_store(stores.refresh_leases.clone()),
|
||||
);
|
||||
let upstream_client: StdArc<dyn CodexModelCatalogClient> = codex.clone();
|
||||
let snapshot_store: StdArc<dyn ModelSnapshotStore> = StdArc::new(
|
||||
crate::upstream::models::SqliteModelSnapshotStore::new(stores.accounts.pool().clone()),
|
||||
@@ -222,7 +232,7 @@ impl Services {
|
||||
codex: codex.clone(),
|
||||
models: models.clone(),
|
||||
account_pool: account_pool.clone(),
|
||||
token_refresher: token_client,
|
||||
token_refresher: StdArc::new(token_client),
|
||||
oauth: crate::admin::accounts::service::oauth::AccountOAuthService::new(
|
||||
reqwest::Client::new(),
|
||||
config.auth.oauth_client_id.clone(),
|
||||
@@ -250,6 +260,7 @@ impl Services {
|
||||
models.clone(),
|
||||
codex.clone(),
|
||||
usage_records.clone(),
|
||||
token_refresh.clone(),
|
||||
installation_id.clone(),
|
||||
cloudflare_recovery.clone(),
|
||||
));
|
||||
@@ -259,6 +270,7 @@ impl Services {
|
||||
codex.clone(),
|
||||
session_affinity.clone(),
|
||||
usage_records.clone(),
|
||||
token_refresh.clone(),
|
||||
installation_id.clone(),
|
||||
cloudflare_recovery,
|
||||
));
|
||||
@@ -275,6 +287,7 @@ impl Services {
|
||||
usage_records,
|
||||
usage,
|
||||
account_pool,
|
||||
token_refresh,
|
||||
chat,
|
||||
responses,
|
||||
session_affinity,
|
||||
|
||||
@@ -4,8 +4,6 @@ use std::path::PathBuf;
|
||||
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::upstream::token_client::{default_openai_token_client, TokenClientConfig};
|
||||
|
||||
use super::{
|
||||
cookie_cleanup::CookieCleanupTask,
|
||||
fingerprint_update::{
|
||||
@@ -68,13 +66,7 @@ impl TaskCoordinator {
|
||||
if config.auth.refresh_enabled {
|
||||
coordinator.push(
|
||||
"token_refresh",
|
||||
TokenRefreshTask::new(
|
||||
stores.accounts.clone(),
|
||||
services.refresh_policy.clone(),
|
||||
default_openai_token_client(token_client_config(config)),
|
||||
)
|
||||
.with_refresh_lease_store(stores.refresh_leases.clone())
|
||||
.start(),
|
||||
TokenRefreshTask::from_service((*services.token_refresh).clone()).start(),
|
||||
);
|
||||
}
|
||||
coordinator.push(
|
||||
@@ -119,13 +111,6 @@ impl TaskCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
fn token_client_config(config: &crate::runtime::state::RuntimeConfig) -> TokenClientConfig {
|
||||
TokenClientConfig {
|
||||
client_id: config.auth.oauth_client_id.clone(),
|
||||
token_endpoint: config.auth.oauth_token_endpoint.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 后台任务关闭句柄。
|
||||
pub enum SchedulerHandle {
|
||||
/// 通过 channel 发送关闭信号。
|
||||
|
||||
@@ -37,6 +37,14 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用已创建的运行时刷新服务构造后台任务。
|
||||
pub fn from_service(service: RuntimeTokenRefreshService<C>) -> Self {
|
||||
Self {
|
||||
service,
|
||||
interval_secs: DEFAULT_INTERVAL_SECS,
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用刷新租约存储保护账号刷新。
|
||||
pub fn with_refresh_lease_store(mut self, refresh_leases: RefreshLeaseStore) -> Self {
|
||||
self.service = self.service.with_refresh_lease_store(refresh_leases);
|
||||
|
||||
@@ -465,7 +465,10 @@ set
|
||||
refresh_token = ?,
|
||||
access_token_expires_at = ?,
|
||||
next_refresh_at = ?,
|
||||
status = ?,
|
||||
status = case
|
||||
when status in ('disabled', 'banned') then status
|
||||
else ?
|
||||
end,
|
||||
updated_at = ?
|
||||
where id = ?";
|
||||
|
||||
@@ -479,7 +482,10 @@ set
|
||||
access_token = ?,
|
||||
access_token_expires_at = ?,
|
||||
next_refresh_at = ?,
|
||||
status = ?,
|
||||
status = case
|
||||
when status in ('disabled', 'banned') then status
|
||||
else ?
|
||||
end,
|
||||
updated_at = ?
|
||||
where id = ?";
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
//! 刷新租约存储、JWT 解码与账号 claims 验证。
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::{
|
||||
collections::hash_map::DefaultHasher,
|
||||
hash::{Hash, Hasher},
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
@@ -217,6 +221,29 @@ impl From<RefreshPolicy> for RuntimeRefreshPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
/// 按账号稳定扰动刷新提前量,范围与 TS 版 `jitter(margin, 0.15)` 一致。
|
||||
pub(crate) fn jittered_refresh_at(
|
||||
account_id: &str,
|
||||
expires_at: DateTime<Utc>,
|
||||
margin_seconds: u64,
|
||||
) -> DateTime<Utc> {
|
||||
expires_at - Duration::seconds(jittered_refresh_margin_seconds(account_id, margin_seconds))
|
||||
}
|
||||
|
||||
fn jittered_refresh_margin_seconds(account_id: &str, margin_seconds: u64) -> i64 {
|
||||
if margin_seconds == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
account_id.hash(&mut hasher);
|
||||
let unit = hasher.finish() as f64 / u64::MAX as f64;
|
||||
let factor = 0.85 + unit * 0.30;
|
||||
(margin_seconds as f64 * factor)
|
||||
.round()
|
||||
.clamp(0.0, i64::MAX as f64) as i64
|
||||
}
|
||||
|
||||
/// 触发刷新动作的原因。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RefreshTrigger {
|
||||
@@ -238,9 +265,6 @@ pub enum RefreshFailure {
|
||||
/// 账号被上游封禁。
|
||||
#[error("account is banned")]
|
||||
Banned,
|
||||
/// 账号被显式禁用。
|
||||
#[error("account is disabled")]
|
||||
Disabled,
|
||||
/// 刷新请求在到达服务端前失败,可安全复用当前 refresh token 重试。
|
||||
#[error("refresh transport failed before server processing")]
|
||||
RetryableTransport,
|
||||
@@ -326,6 +350,7 @@ where
|
||||
|
||||
match self.client.refresh(refresh_token).await {
|
||||
Ok(token_pair) => Ok(apply_token_pair(account, token_pair)),
|
||||
Err(RefreshFailure::QuotaExhausted) => Err(RefreshError::Transport),
|
||||
Err(RefreshFailure::RetryableTransport) => Err(RefreshError::RetryableTransport),
|
||||
Err(RefreshFailure::Transport) => Err(RefreshError::Transport),
|
||||
Err(error) => Ok(apply_refresh_failure(account, error)),
|
||||
@@ -388,7 +413,6 @@ pub fn apply_refresh_failure(account: &Account, failure: RefreshFailure) -> Acco
|
||||
RefreshFailure::InvalidGrant => AccountStatus::Expired,
|
||||
RefreshFailure::QuotaExhausted => AccountStatus::QuotaExhausted,
|
||||
RefreshFailure::Banned => AccountStatus::Banned,
|
||||
RefreshFailure::Disabled => AccountStatus::Disabled,
|
||||
RefreshFailure::RetryableTransport => AccountStatus::Active,
|
||||
RefreshFailure::Transport => AccountStatus::Active,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! 运行时 token refresh 业务服务。
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
collections::{hash_map::DefaultHasher, HashMap, HashSet},
|
||||
future::Future,
|
||||
hash::{Hash, Hasher},
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
@@ -22,8 +23,9 @@ use crate::upstream::accounts::{
|
||||
StoredAccount,
|
||||
},
|
||||
token_refresh::{
|
||||
jwt_expiration, RefreshError, RefreshLeaseStore, RefreshLeaseStoreError, RefreshPolicy,
|
||||
RefreshScheduler, RefreshTrigger, RuntimeRefreshPolicy, TokenRefresher,
|
||||
jittered_refresh_at, jwt_expiration, RefreshError, RefreshLeaseStore,
|
||||
RefreshLeaseStoreError, RefreshPolicy, RefreshScheduler, RefreshTrigger,
|
||||
RuntimeRefreshPolicy, TokenRefresher,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -33,6 +35,8 @@ const REFRESH_RETRY_BASE_DELAY_MILLIS: u64 = 5_000;
|
||||
const REFRESH_RETRY_MAX_DELAY_MILLIS: u64 = 300_000;
|
||||
const PERMANENT_FAILURE_CONFIRMATION_THRESHOLD: usize = 2;
|
||||
const RECOVERY_DELAY_SECONDS: i64 = 10 * 60;
|
||||
const RETRY_DELAY_JITTER: f64 = 0.30;
|
||||
const RECOVERY_DELAY_JITTER: f64 = 0.20;
|
||||
|
||||
/// 运行时 token refresh 服务。
|
||||
pub struct RuntimeTokenRefreshService<C>
|
||||
@@ -226,6 +230,32 @@ where
|
||||
self.timers.lock().await.len()
|
||||
}
|
||||
|
||||
/// 立即刷新被上游认证失败命中的账号。
|
||||
pub async fn trigger_account_refresh_now(
|
||||
&self,
|
||||
account_id: &str,
|
||||
) -> TokenRefreshServiceResult<()> {
|
||||
let Some(account) = self.store.get(account_id).await? else {
|
||||
return Err(TokenRefreshServiceError::AccountNotFound(
|
||||
account_id.to_string(),
|
||||
));
|
||||
};
|
||||
if account.refresh_token.is_none()
|
||||
|| matches!(
|
||||
account.status,
|
||||
AccountStatus::Disabled | AccountStatus::Banned
|
||||
)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.clear_scheduled_timer(account_id).await;
|
||||
persist_next_refresh_at(&self.store, account_id, None).await?;
|
||||
self.refresh_scheduled_account(account_id, RefreshTrigger::Unauthorized, Utc::now())
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// 在指定时间点执行一次到期刷新扫描。
|
||||
pub async fn refresh_due_accounts_once_at(
|
||||
&self,
|
||||
@@ -310,8 +340,11 @@ where
|
||||
let expires_at = account
|
||||
.access_token_expires_at
|
||||
.or_else(|| jwt_expiration(&account.access_token))?;
|
||||
let margin_seconds = self.policy.refresh_margin_seconds().min(i64::MAX as u64) as i64;
|
||||
Some(expires_at - chrono::Duration::seconds(margin_seconds))
|
||||
Some(jittered_refresh_at(
|
||||
&account.id,
|
||||
expires_at,
|
||||
self.policy.refresh_margin_seconds(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn persist_next_refresh_at_if_changed(
|
||||
@@ -521,7 +554,7 @@ where
|
||||
if is_permanent_refresh_status(updated.status) {
|
||||
permanent_failures += 1;
|
||||
if permanent_failures < PERMANENT_FAILURE_CONFIRMATION_THRESHOLD
|
||||
&& self.sleep_before_retry(attempt_index).await
|
||||
&& self.sleep_before_retry(&attempt.id, attempt_index).await
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -532,11 +565,17 @@ where
|
||||
return Ok(TokenRefreshOutcome::StatusUpdated);
|
||||
}
|
||||
Ok(_) => return Ok(TokenRefreshOutcome::Skipped),
|
||||
Err(RefreshError::RetryableTransport)
|
||||
if self.sleep_before_retry(attempt_index).await => {}
|
||||
Err(RefreshError::RetryableTransport | RefreshError::Transport)
|
||||
if self.sleep_before_retry(&attempt.id, attempt_index).await => {}
|
||||
Err(RefreshError::RetryableTransport | RefreshError::Transport) => {
|
||||
persist_status(&self.store, &attempt.id, AccountStatus::Active).await?;
|
||||
let next_refresh_at = now + chrono::Duration::seconds(RECOVERY_DELAY_SECONDS);
|
||||
let recovery_delay = stable_jittered_duration(
|
||||
&attempt.id,
|
||||
Duration::from_secs(RECOVERY_DELAY_SECONDS as u64),
|
||||
RECOVERY_DELAY_JITTER,
|
||||
"recovery",
|
||||
);
|
||||
let next_refresh_at = scheduled_at_from_delay(now, recovery_delay);
|
||||
persist_next_refresh_at(&self.store, &attempt.id, Some(next_refresh_at))
|
||||
.await?;
|
||||
return Ok(TokenRefreshOutcome::Failed);
|
||||
@@ -576,11 +615,17 @@ where
|
||||
Ok(Some(attempt))
|
||||
}
|
||||
|
||||
async fn sleep_before_retry(&self, attempt_index: usize) -> bool {
|
||||
async fn sleep_before_retry(&self, account_id: &str, attempt_index: usize) -> bool {
|
||||
let Some(delay) = self.retry_delays.get(attempt_index).copied() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let delay = stable_jittered_duration(
|
||||
account_id,
|
||||
delay,
|
||||
RETRY_DELAY_JITTER,
|
||||
&format!("retry:{attempt_index}"),
|
||||
);
|
||||
if !delay.is_zero() {
|
||||
sleep(delay).await;
|
||||
}
|
||||
@@ -792,6 +837,27 @@ fn default_refresh_retry_delays() -> Vec<Duration> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn stable_jittered_duration(
|
||||
account_id: &str,
|
||||
base: Duration,
|
||||
variance: f64,
|
||||
salt: &str,
|
||||
) -> Duration {
|
||||
if base.is_zero() {
|
||||
return Duration::ZERO;
|
||||
}
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
account_id.hash(&mut hasher);
|
||||
salt.hash(&mut hasher);
|
||||
let unit = hasher.finish() as f64 / u64::MAX as f64;
|
||||
let factor = (1.0 - variance) + unit * variance * 2.0;
|
||||
let millis = (base.as_millis() as f64 * factor)
|
||||
.round()
|
||||
.clamp(0.0, u64::MAX as f64) as u64;
|
||||
Duration::from_millis(millis)
|
||||
}
|
||||
|
||||
fn scheduled_at_from_delay(now: DateTime<Utc>, delay: Duration) -> DateTime<Utc> {
|
||||
match chrono::Duration::from_std(delay) {
|
||||
Ok(delay) => now + delay,
|
||||
|
||||
@@ -77,25 +77,15 @@ fn parse_token_pair(body: &str) -> Result<TokenPair, ()> {
|
||||
})
|
||||
}
|
||||
|
||||
fn classify_refresh_failure(status: StatusCode, body: &str) -> RefreshFailure {
|
||||
fn classify_refresh_failure(_status: StatusCode, body: &str) -> RefreshFailure {
|
||||
let lower = body.to_ascii_lowercase();
|
||||
if lower.contains("quota") {
|
||||
return RefreshFailure::QuotaExhausted;
|
||||
}
|
||||
if lower.contains("banned") || lower.contains("account has been deactivated") {
|
||||
if lower.contains("account has been deactivated") || lower.contains("refresh_token_reused") {
|
||||
return RefreshFailure::Banned;
|
||||
}
|
||||
if lower.contains("account disabled") {
|
||||
return RefreshFailure::Disabled;
|
||||
}
|
||||
if status == StatusCode::BAD_REQUEST
|
||||
|| status == StatusCode::UNAUTHORIZED
|
||||
|| lower.contains("invalid_grant")
|
||||
if lower.contains("invalid_grant")
|
||||
|| lower.contains("invalid_token")
|
||||
|| lower.contains("access_denied")
|
||||
|| lower.contains("refresh_token_expired")
|
||||
|| lower.contains("refresh_token_reused")
|
||||
|| lower.contains("token_revoked")
|
||||
{
|
||||
return RefreshFailure::InvalidGrant;
|
||||
}
|
||||
|
||||
@@ -146,10 +146,15 @@ pub fn is_banned_upstream_error(error: &CodexClientError) -> bool {
|
||||
matches!(
|
||||
error,
|
||||
CodexClientError::Upstream { status, body, .. }
|
||||
if status.as_u16() == 403 && is_banned_auth_signal(body)
|
||||
if status.as_u16() == 403 && !is_html_error_body(body)
|
||||
)
|
||||
}
|
||||
|
||||
fn is_html_error_body(value: &str) -> bool {
|
||||
let value = value.trim_start().to_ascii_lowercase();
|
||||
value.starts_with("<!doctype") || value.starts_with("<html") || value.contains("<html")
|
||||
}
|
||||
|
||||
/// Codex 客户端结果类型。
|
||||
pub type CodexClientResult<T> = Result<T, CodexClientError>;
|
||||
|
||||
|
||||
@@ -255,7 +255,8 @@ async fn admin_accounts_import_should_complete_chatgpt_account_id_from_refresh_t
|
||||
.await
|
||||
.unwrap();
|
||||
let body = response_json(response).await;
|
||||
let stored = SqliteAccountStore::new(pool)
|
||||
let store = SqliteAccountStore::new(pool);
|
||||
let stored = store
|
||||
.list_metadata_page(1, 10, None)
|
||||
.await
|
||||
.unwrap()
|
||||
@@ -285,6 +286,68 @@ async fn admin_accounts_import_should_complete_chatgpt_account_id_from_refresh_t
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_accounts_import_from_refresh_token_should_not_store_consumed_refresh_token_when_not_rotated(
|
||||
) {
|
||||
let server = MockServer::start().await;
|
||||
let access_token = test_jwt(
|
||||
"rt-import-no-rotation-account",
|
||||
Some("rt-import-no-rotation-user"),
|
||||
Some("rt-import-no-rotation@example.com"),
|
||||
Some("plus"),
|
||||
);
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"access_token": access_token,
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let (app, _state, pool, _dir) = admin_accounts_test_app_with_oauth_token_endpoint(
|
||||
"admin-accounts-import-rt-no-rotation.sqlite",
|
||||
126,
|
||||
format!("{}/oauth/token", server.uri()),
|
||||
)
|
||||
.await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/accounts/import")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_accounts_import_rt_no_rotation")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"accounts": [{ "refreshToken": "rt-import-no-rotation-source" }]
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = response_json(response).await;
|
||||
let store = SqliteAccountStore::new(pool);
|
||||
let stored = store
|
||||
.list_metadata_page(1, 10, None)
|
||||
.await
|
||||
.unwrap()
|
||||
.items
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(body["data"]["imported"], 1);
|
||||
assert_eq!(
|
||||
stored.account_id.as_deref(),
|
||||
Some("rt-import-no-rotation-account")
|
||||
);
|
||||
let account = store.get(&stored.id).await.unwrap().unwrap();
|
||||
assert!(account.refresh_token.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_accounts_import_should_fetch_usage_to_complete_missing_plan_and_quota() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
@@ -262,7 +262,7 @@ async fn admin_account_status_update_should_update_proxy_account_pool() {
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.body(Body::from(
|
||||
json!({"id": "acct_runtime_status", "status": "banned"}).to_string(),
|
||||
json!({"id": "acct_runtime_status", "status": "disabled"}).to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
@@ -282,7 +282,61 @@ async fn admin_account_status_update_should_update_proxy_account_pool() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_account_refresh_should_not_mark_valid_account_banned_when_refresh_token_reused() {
|
||||
async fn admin_account_status_update_should_reject_non_manual_statuses() {
|
||||
let (app, _state, pool, _dir) =
|
||||
admin_accounts_test_app("admin-account-status-reject-banned.sqlite", 125).await;
|
||||
seed_account(
|
||||
&pool,
|
||||
NewAccount {
|
||||
id: "acct_reject_status".to_string(),
|
||||
email: Some("reject-status@example.com".to_string()),
|
||||
account_id: Some("chatgpt-reject-status".to_string()),
|
||||
user_id: Some("user-reject-status".to_string()),
|
||||
label: None,
|
||||
plan_type: None,
|
||||
access_token: SecretString::new(
|
||||
test_jwt(
|
||||
"chatgpt-reject-status",
|
||||
Some("user-reject-status"),
|
||||
Some("reject-status@example.com"),
|
||||
Some("free"),
|
||||
)
|
||||
.into(),
|
||||
),
|
||||
refresh_token: None,
|
||||
access_token_expires_at: None,
|
||||
status: AccountStatus::Active,
|
||||
added_at: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/accounts/update")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.body(Body::from(
|
||||
json!({"id": "acct_reject_status", "status": "banned"}).to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let stored = SqliteAccountStore::new(pool)
|
||||
.get("acct_reject_status")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(stored.status, AccountStatus::Active);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_account_refresh_should_mark_account_expired_when_refresh_token_reused() {
|
||||
let server = wiremock::MockServer::start().await;
|
||||
wiremock::Mock::given(wiremock::matchers::method("POST"))
|
||||
.and(wiremock::matchers::path("/oauth/token"))
|
||||
@@ -338,16 +392,355 @@ async fn admin_account_refresh_should_not_mark_valid_account_banned_when_refresh
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = response_json(response).await;
|
||||
let stored = SqliteAccountStore::new(pool)
|
||||
.get("acct_refresh_rt_reused")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["data"]["result"], "dead");
|
||||
assert_eq!(body["data"]["previousStatus"], "active");
|
||||
assert_eq!(stored.status, AccountStatus::Expired);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_account_refresh_should_skip_disabled_account_without_consuming_refresh_token() {
|
||||
let server = wiremock::MockServer::start().await;
|
||||
wiremock::Mock::given(wiremock::matchers::method("POST"))
|
||||
.and(wiremock::matchers::path("/oauth/token"))
|
||||
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({
|
||||
"access_token": test_jwt(
|
||||
"chatgpt-disabled-refresh",
|
||||
Some("user-disabled-refresh"),
|
||||
Some("disabled-refresh@example.com"),
|
||||
Some("plus"),
|
||||
),
|
||||
"refresh_token": "refresh-disabled-rotated"
|
||||
})))
|
||||
.expect(0)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let (app, _state, pool, _dir) = admin_accounts_test_app_with_oauth_token_endpoint(
|
||||
"admin-account-refresh-disabled-skip.sqlite",
|
||||
124,
|
||||
format!("{}/oauth/token", server.uri()),
|
||||
)
|
||||
.await;
|
||||
seed_account(
|
||||
&pool,
|
||||
NewAccount {
|
||||
id: "acct_refresh_disabled".to_string(),
|
||||
email: Some("disabled-refresh@example.com".to_string()),
|
||||
account_id: Some("chatgpt-disabled-refresh".to_string()),
|
||||
user_id: Some("user-disabled-refresh".to_string()),
|
||||
label: None,
|
||||
plan_type: None,
|
||||
access_token: SecretString::new(
|
||||
test_jwt(
|
||||
"chatgpt-disabled-refresh",
|
||||
Some("user-disabled-refresh"),
|
||||
Some("disabled-refresh@example.com"),
|
||||
Some("plus"),
|
||||
)
|
||||
.into(),
|
||||
),
|
||||
refresh_token: Some(SecretString::new("refresh-disabled".to_string().into())),
|
||||
access_token_expires_at: None,
|
||||
status: AccountStatus::Disabled,
|
||||
added_at: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/accounts/refresh")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.body(Body::from(
|
||||
json!({"id": "acct_refresh_disabled"}).to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = response_json(response).await;
|
||||
let stored = SqliteAccountStore::new(pool)
|
||||
.get("acct_refresh_disabled")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["data"]["result"], "skipped");
|
||||
assert_eq!(body["data"]["error"], "manually disabled");
|
||||
assert_eq!(stored.status, AccountStatus::Disabled);
|
||||
assert_eq!(
|
||||
stored.refresh_token.unwrap().expose_secret(),
|
||||
"refresh-disabled"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_account_refresh_should_skip_account_without_refresh_token() {
|
||||
let (app, _state, pool, _dir) =
|
||||
admin_accounts_test_app("admin-account-refresh-no-rt-skip.sqlite", 124).await;
|
||||
seed_account(
|
||||
&pool,
|
||||
NewAccount {
|
||||
id: "acct_refresh_no_rt".to_string(),
|
||||
email: Some("no-rt-refresh@example.com".to_string()),
|
||||
account_id: Some("chatgpt-no-rt-refresh".to_string()),
|
||||
user_id: Some("user-no-rt-refresh".to_string()),
|
||||
label: None,
|
||||
plan_type: None,
|
||||
access_token: SecretString::new(
|
||||
test_jwt(
|
||||
"chatgpt-no-rt-refresh",
|
||||
Some("user-no-rt-refresh"),
|
||||
Some("no-rt-refresh@example.com"),
|
||||
Some("plus"),
|
||||
)
|
||||
.into(),
|
||||
),
|
||||
refresh_token: None,
|
||||
access_token_expires_at: None,
|
||||
status: AccountStatus::Active,
|
||||
added_at: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/accounts/refresh")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.body(Body::from(json!({"id": "acct_refresh_no_rt"}).to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = response_json(response).await;
|
||||
let stored = SqliteAccountStore::new(pool)
|
||||
.get("acct_refresh_no_rt")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["data"]["result"], "skipped");
|
||||
assert_eq!(body["data"]["error"], "no refresh token");
|
||||
assert_eq!(stored.status, AccountStatus::Active);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_account_refresh_should_not_change_status_for_non_permanent_refresh_error() {
|
||||
let server = wiremock::MockServer::start().await;
|
||||
wiremock::Mock::given(wiremock::matchers::method("POST"))
|
||||
.and(wiremock::matchers::path("/oauth/token"))
|
||||
.respond_with(wiremock::ResponseTemplate::new(400).set_body_string("quota exceeded"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let (app, _state, pool, _dir) = admin_accounts_test_app_with_oauth_token_endpoint(
|
||||
"admin-account-refresh-quota-temporary.sqlite",
|
||||
124,
|
||||
format!("{}/oauth/token", server.uri()),
|
||||
)
|
||||
.await;
|
||||
seed_account(
|
||||
&pool,
|
||||
NewAccount {
|
||||
id: "acct_refresh_quota".to_string(),
|
||||
email: Some("quota-refresh@example.com".to_string()),
|
||||
account_id: Some("chatgpt-quota-refresh".to_string()),
|
||||
user_id: Some("user-quota-refresh".to_string()),
|
||||
label: None,
|
||||
plan_type: None,
|
||||
access_token: SecretString::new(
|
||||
test_jwt(
|
||||
"chatgpt-quota-refresh",
|
||||
Some("user-quota-refresh"),
|
||||
Some("quota-refresh@example.com"),
|
||||
Some("plus"),
|
||||
)
|
||||
.into(),
|
||||
),
|
||||
refresh_token: Some(SecretString::new("refresh-quota".to_string().into())),
|
||||
access_token_expires_at: None,
|
||||
status: AccountStatus::Active,
|
||||
added_at: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/accounts/refresh")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.body(Body::from(json!({"id": "acct_refresh_quota"}).to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = response_json(response).await;
|
||||
let stored = SqliteAccountStore::new(pool)
|
||||
.get("acct_refresh_quota")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["data"]["result"], "dead");
|
||||
assert_eq!(stored.status, AccountStatus::Active);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_accounts_health_check_should_return_alive_dead_and_skipped_summary() {
|
||||
let server = wiremock::MockServer::start().await;
|
||||
wiremock::Mock::given(wiremock::matchers::method("POST"))
|
||||
.and(wiremock::matchers::path("/oauth/token"))
|
||||
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({
|
||||
"access_token": test_jwt(
|
||||
"chatgpt-health-alive",
|
||||
Some("user-health-alive"),
|
||||
Some("health-alive@example.com"),
|
||||
Some("plus"),
|
||||
),
|
||||
"refresh_token": "refresh-health-alive-rotated"
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let (app, _state, pool, _dir) = admin_accounts_test_app_with_oauth_token_endpoint(
|
||||
"admin-accounts-health-check.sqlite",
|
||||
124,
|
||||
format!("{}/oauth/token", server.uri()),
|
||||
)
|
||||
.await;
|
||||
seed_account(
|
||||
&pool,
|
||||
NewAccount {
|
||||
id: "acct_health_alive".to_string(),
|
||||
email: Some("health-alive@example.com".to_string()),
|
||||
account_id: Some("chatgpt-health-alive".to_string()),
|
||||
user_id: Some("user-health-alive".to_string()),
|
||||
label: None,
|
||||
plan_type: None,
|
||||
access_token: SecretString::new(
|
||||
test_jwt(
|
||||
"chatgpt-health-alive",
|
||||
Some("user-health-alive"),
|
||||
Some("health-alive@example.com"),
|
||||
Some("plus"),
|
||||
)
|
||||
.into(),
|
||||
),
|
||||
refresh_token: Some(SecretString::new("refresh-health-alive".to_string().into())),
|
||||
access_token_expires_at: None,
|
||||
status: AccountStatus::Active,
|
||||
added_at: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
seed_account(
|
||||
&pool,
|
||||
NewAccount {
|
||||
id: "acct_health_no_rt".to_string(),
|
||||
email: Some("health-no-rt@example.com".to_string()),
|
||||
account_id: Some("chatgpt-health-no-rt".to_string()),
|
||||
user_id: Some("user-health-no-rt".to_string()),
|
||||
label: None,
|
||||
plan_type: None,
|
||||
access_token: SecretString::new(
|
||||
test_jwt(
|
||||
"chatgpt-health-no-rt",
|
||||
Some("user-health-no-rt"),
|
||||
Some("health-no-rt@example.com"),
|
||||
Some("plus"),
|
||||
)
|
||||
.into(),
|
||||
),
|
||||
refresh_token: None,
|
||||
access_token_expires_at: None,
|
||||
status: AccountStatus::Active,
|
||||
added_at: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
seed_account(
|
||||
&pool,
|
||||
NewAccount {
|
||||
id: "acct_health_disabled".to_string(),
|
||||
email: Some("health-disabled@example.com".to_string()),
|
||||
account_id: Some("chatgpt-health-disabled".to_string()),
|
||||
user_id: Some("user-health-disabled".to_string()),
|
||||
label: None,
|
||||
plan_type: None,
|
||||
access_token: SecretString::new(
|
||||
test_jwt(
|
||||
"chatgpt-health-disabled",
|
||||
Some("user-health-disabled"),
|
||||
Some("health-disabled@example.com"),
|
||||
Some("plus"),
|
||||
)
|
||||
.into(),
|
||||
),
|
||||
refresh_token: Some(SecretString::new(
|
||||
"refresh-health-disabled".to_string().into(),
|
||||
)),
|
||||
access_token_expires_at: None,
|
||||
status: AccountStatus::Disabled,
|
||||
added_at: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/accounts/health-check")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.body(Body::from(json!({"stagger_ms": 0}).to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = response_json(response).await;
|
||||
let stored_alive = SqliteAccountStore::new(pool)
|
||||
.get("acct_health_alive")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["data"]["summary"]["total"], 3);
|
||||
assert_eq!(body["data"]["summary"]["alive"], 1);
|
||||
assert_eq!(body["data"]["summary"]["dead"], 0);
|
||||
assert_eq!(body["data"]["summary"]["skipped"], 2);
|
||||
assert_eq!(stored_alive.status, AccountStatus::Active);
|
||||
assert_eq!(
|
||||
stored_alive.refresh_token.unwrap().expose_secret(),
|
||||
"refresh-health-alive-rotated"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_account_create_should_derive_claims_and_store_plain_tokens() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -438,6 +831,43 @@ async fn admin_account_manual_create_should_reject_missing_invalid_expired_or_un
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_account_manual_create_from_refresh_token_should_not_store_consumed_refresh_token_when_not_rotated(
|
||||
) {
|
||||
let server = wiremock::MockServer::start().await;
|
||||
let access_token = test_jwt(
|
||||
"rt-create-account",
|
||||
Some("rt-create-user"),
|
||||
Some("rt-create@example.com"),
|
||||
Some("plus"),
|
||||
);
|
||||
wiremock::Mock::given(wiremock::matchers::method("POST"))
|
||||
.and(wiremock::matchers::path("/oauth/token"))
|
||||
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({
|
||||
"access_token": access_token,
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let (app, _state, pool, _dir) = admin_accounts_test_app_with_oauth_token_endpoint(
|
||||
"admin-account-create-rt-no-rotation.sqlite",
|
||||
126,
|
||||
format!("{}/oauth/token", server.uri()),
|
||||
)
|
||||
.await;
|
||||
|
||||
let response = post_admin_account(&app, json!({"refreshToken": "rt-create-source"})).await;
|
||||
let status = response.status();
|
||||
let body = response_json(response).await;
|
||||
let stored = SqliteAccountStore::new(pool)
|
||||
.get(body["data"]["id"].as_str().unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(stored.refresh_token.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_account_manual_create_should_accept_current_openai_token_without_chatgpt_account_id()
|
||||
{
|
||||
|
||||
@@ -840,7 +840,7 @@ async fn chat_completions_should_mark_banned_after_403_and_fallback() {
|
||||
.and(path("/codex/responses"))
|
||||
.and(header("authorization", "Bearer access-primary"))
|
||||
.respond_with(ResponseTemplate::new(403).set_body_json(json!({
|
||||
"error": {"message": "account banned"}
|
||||
"error": {"message": "request forbidden"}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
|
||||
@@ -418,7 +418,7 @@ async fn responses_should_mark_banned_after_403_and_fallback() {
|
||||
.and(path("/codex/responses"))
|
||||
.and(header("authorization", "Bearer access-primary"))
|
||||
.respond_with(ResponseTemplate::new(403).set_body_json(json!({
|
||||
"error": {"message": "account banned"}
|
||||
"error": {"message": "request forbidden"}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
@@ -1524,7 +1524,7 @@ async fn responses_stream_should_mark_banned_after_403_and_fallback() {
|
||||
.and(path("/codex/responses"))
|
||||
.and(header("authorization", "Bearer access-primary"))
|
||||
.respond_with(ResponseTemplate::new(403).set_body_json(json!({
|
||||
"error": {"message": "account banned"}
|
||||
"error": {"message": "request forbidden"}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
|
||||
@@ -693,7 +693,7 @@ async fn responses_websocket_should_implicitly_resume_full_history_with_reasonin
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_websocket_should_strip_implicit_banned_affinity_when_switching_accounts() {
|
||||
async fn responses_websocket_should_strip_implicit_disabled_affinity_when_switching_accounts() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let upstream = tokio::spawn(async move {
|
||||
@@ -746,7 +746,7 @@ async fn responses_websocket_should_strip_implicit_banned_affinity_when_switchin
|
||||
.await
|
||||
.contains("\"id\":\"resp_implicit_ban_first\""));
|
||||
|
||||
update_admin_account_status(&app, "acct_primary", "banned").await;
|
||||
update_admin_account_status(&app, "acct_primary", "disabled").await;
|
||||
|
||||
let second_response = app
|
||||
.oneshot(responses_json_request(
|
||||
|
||||
@@ -3,7 +3,7 @@ use codex_proxy_rs::{
|
||||
infra::database::connect_sqlite,
|
||||
upstream::accounts::{
|
||||
model::{AccountStatus, AccountUsageDelta},
|
||||
store::{AccountStore, NewAccount, SqliteAccountStore},
|
||||
store::{AccountClaimsUpdate, AccountStore, NewAccount, SqliteAccountStore},
|
||||
},
|
||||
};
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
@@ -155,6 +155,64 @@ async fn account_repository_should_update_status_and_label_without_rewriting_tok
|
||||
assert!(updated_at.0.parse::<DateTime<Utc>>().is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_repository_should_not_reactivate_disabled_or_banned_accounts_from_claims_update() {
|
||||
let (pool, _dir) = sqlite_account_store_parts("accounts.sqlite", 11).await;
|
||||
let repo = SqliteAccountStore::new(pool);
|
||||
seed_new_account(&repo, "acct_disabled").await;
|
||||
seed_new_account(&repo, "acct_banned").await;
|
||||
repo.set_status("acct_disabled", AccountStatus::Disabled)
|
||||
.await
|
||||
.expect("disabled status should persist");
|
||||
repo.set_status("acct_banned", AccountStatus::Banned)
|
||||
.await
|
||||
.expect("banned status should persist");
|
||||
|
||||
for account_id in ["acct_disabled", "acct_banned"] {
|
||||
assert!(repo
|
||||
.update_from_claims(
|
||||
account_id,
|
||||
AccountClaimsUpdate {
|
||||
email: Some(format!("{account_id}@example.com")),
|
||||
account_id: Some(format!("chatgpt-{account_id}")),
|
||||
user_id: Some(format!("user-{account_id}")),
|
||||
plan_type: Some("plus".to_string()),
|
||||
access_token: SecretString::new(format!("new-access-{account_id}").into()),
|
||||
refresh_token: Some(SecretString::new(
|
||||
format!("new-refresh-{account_id}").into(),
|
||||
)),
|
||||
access_token_expires_at: Some(Utc::now() + Duration::hours(1)),
|
||||
next_refresh_at: Some(Utc::now() + Duration::minutes(30)),
|
||||
status: AccountStatus::Active,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("claims update should persist"));
|
||||
}
|
||||
|
||||
let disabled = repo
|
||||
.get("acct_disabled")
|
||||
.await
|
||||
.expect("account should load")
|
||||
.expect("account should exist");
|
||||
let banned = repo
|
||||
.get("acct_banned")
|
||||
.await
|
||||
.expect("account should load")
|
||||
.expect("account should exist");
|
||||
|
||||
assert_eq!(disabled.status, AccountStatus::Disabled);
|
||||
assert_eq!(
|
||||
disabled.access_token.expose_secret(),
|
||||
"new-access-acct_disabled"
|
||||
);
|
||||
assert_eq!(banned.status, AccountStatus::Banned);
|
||||
assert_eq!(
|
||||
banned.access_token.expose_secret(),
|
||||
"new-access-acct_banned"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_repository_should_accumulate_usage_counters() {
|
||||
let (pool, _dir) = sqlite_account_store_parts("accounts.sqlite", 7).await;
|
||||
|
||||
@@ -129,7 +129,7 @@ async fn token_refresh_task_should_recover_refreshing_account_after_restart() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_refresh_task_should_not_retry_ambiguous_transport_failure() {
|
||||
async fn token_refresh_task_should_retry_transport_failure_before_recovery() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let db = dir.path().join("token-refresh-transport-failure.sqlite");
|
||||
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
|
||||
@@ -183,7 +183,7 @@ async fn token_refresh_task_should_not_retry_ambiguous_transport_failure() {
|
||||
let observed_statuses = observed_statuses.lock().await.clone();
|
||||
|
||||
assert_eq!(summary.failed, 1);
|
||||
assert_eq!(observed_statuses, [AccountStatus::Refreshing]);
|
||||
assert_eq!(observed_statuses, [AccountStatus::Refreshing; 5]);
|
||||
assert_eq!(stored.status, AccountStatus::Active);
|
||||
assert!(stored.next_refresh_at.is_some_and(|next| next > now));
|
||||
assert_eq!(
|
||||
@@ -248,12 +248,19 @@ async fn token_refresh_task_should_delay_recovery_after_retry_exhaustion() {
|
||||
.refresh_due_accounts_once_at(now)
|
||||
.await
|
||||
.expect("first scan should summarize retry exhaustion");
|
||||
let recovery_at = store
|
||||
.get("acct-refresh-delayed-recovery")
|
||||
.await
|
||||
.expect("account should load")
|
||||
.expect("account should exist")
|
||||
.next_refresh_at
|
||||
.expect("retry exhaustion should persist recovery time");
|
||||
let delayed = task
|
||||
.refresh_due_accounts_once_at(now + Duration::minutes(5))
|
||||
.await
|
||||
.expect("recovery window should skip refresh");
|
||||
let refreshed = task
|
||||
.refresh_due_accounts_once_at(now + Duration::minutes(10) + Duration::seconds(1))
|
||||
.refresh_due_accounts_once_at(recovery_at + Duration::seconds(1))
|
||||
.await
|
||||
.expect("recovery window should allow refresh");
|
||||
let stored = store
|
||||
@@ -264,6 +271,8 @@ async fn token_refresh_task_should_delay_recovery_after_retry_exhaustion() {
|
||||
let observed_statuses = observed_statuses.lock().await.clone();
|
||||
|
||||
assert_eq!(failed.failed, 1);
|
||||
assert!(recovery_at >= now + Duration::minutes(8));
|
||||
assert!(recovery_at <= now + Duration::minutes(12));
|
||||
assert_eq!(delayed.skipped, 1);
|
||||
assert_eq!(refreshed.refreshed, 1);
|
||||
assert_eq!(observed_statuses, [AccountStatus::Refreshing; 6]);
|
||||
|
||||
@@ -183,7 +183,7 @@ async fn token_refresh_task_should_schedule_future_per_account_timer_without_ref
|
||||
.expect("account should be inserted");
|
||||
let refresher = CountingTokenRefresher::default();
|
||||
let task = codex_proxy_rs::upstream::accounts::token_refresh::RuntimeTokenRefreshService::new(
|
||||
store,
|
||||
store.clone(),
|
||||
RefreshPolicy {
|
||||
refresh_margin_seconds: 300,
|
||||
refresh_concurrency: 1,
|
||||
@@ -195,11 +195,150 @@ async fn token_refresh_task_should_schedule_future_per_account_timer_without_ref
|
||||
.schedule_account_timers_once_at(now)
|
||||
.await
|
||||
.expect("timer scheduling should succeed");
|
||||
let stored = store
|
||||
.get("acct-refresh-future-timer")
|
||||
.await
|
||||
.expect("account should load")
|
||||
.expect("account should exist");
|
||||
tokio::task::yield_now().await;
|
||||
|
||||
assert_eq!(summary.scheduled, 1);
|
||||
assert_eq!(task.scheduled_timer_count().await, 1);
|
||||
assert_eq!(refresher.calls.load(Ordering::SeqCst), 0);
|
||||
let scheduled_at = stored
|
||||
.next_refresh_at
|
||||
.expect("future refresh timer should persist next_refresh_at");
|
||||
let exact_margin_at = now + Duration::minutes(5);
|
||||
assert_ne!(scheduled_at, exact_margin_at);
|
||||
assert!(scheduled_at >= now + Duration::seconds(255));
|
||||
assert!(scheduled_at <= now + Duration::seconds(345));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_refresh_task_should_refresh_immediately_after_unauthorized_even_with_future_timer() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let db = dir.path().join("token-refresh-unauthorized-now.sqlite");
|
||||
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
|
||||
.await
|
||||
.expect("sqlite pool");
|
||||
let store = SqliteAccountStore::new(pool);
|
||||
let now = Utc::now();
|
||||
let old_access_token = test_jwt((now + Duration::minutes(10)).timestamp());
|
||||
let new_access_token = test_jwt((now + Duration::hours(1)).timestamp());
|
||||
store
|
||||
.insert(NewAccount {
|
||||
id: "acct-refresh-unauthorized-now".to_string(),
|
||||
email: Some("unauthorized-now@example.com".to_string()),
|
||||
account_id: Some("chatgpt-unauthorized-now".to_string()),
|
||||
user_id: Some("user-unauthorized-now".to_string()),
|
||||
label: None,
|
||||
plan_type: Some("plus".to_string()),
|
||||
access_token: SecretString::new(old_access_token.into()),
|
||||
refresh_token: Some(SecretString::new(
|
||||
"refresh-unauthorized-now".to_string().into(),
|
||||
)),
|
||||
added_at: None,
|
||||
access_token_expires_at: Some(now + Duration::minutes(10)),
|
||||
status: AccountStatus::Active,
|
||||
})
|
||||
.await
|
||||
.expect("account should be inserted");
|
||||
let refresher = NotifyingTokenRefresher {
|
||||
calls: Arc::new(AtomicUsize::new(0)),
|
||||
started: Arc::new(Notify::new()),
|
||||
response: Ok(TokenPair {
|
||||
access_token: new_access_token.clone(),
|
||||
refresh_token: None,
|
||||
}),
|
||||
};
|
||||
let task = codex_proxy_rs::upstream::accounts::token_refresh::RuntimeTokenRefreshService::new(
|
||||
store.clone(),
|
||||
RefreshPolicy {
|
||||
refresh_margin_seconds: 300,
|
||||
refresh_concurrency: 1,
|
||||
},
|
||||
refresher.clone(),
|
||||
)
|
||||
.with_retry_delays(vec![StdDuration::ZERO; 4]);
|
||||
task.schedule_account_timers_once_at(now)
|
||||
.await
|
||||
.expect("future timer should be scheduled");
|
||||
assert_eq!(task.scheduled_timer_count().await, 1);
|
||||
assert!(store
|
||||
.set_status("acct-refresh-unauthorized-now", AccountStatus::Expired)
|
||||
.await
|
||||
.expect("expired status should persist"));
|
||||
|
||||
task.trigger_account_refresh_now("acct-refresh-unauthorized-now")
|
||||
.await
|
||||
.expect("unauthorized refresh should run immediately");
|
||||
let stored = store
|
||||
.get("acct-refresh-unauthorized-now")
|
||||
.await
|
||||
.expect("account should load")
|
||||
.expect("account should exist");
|
||||
|
||||
assert_eq!(refresher.calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(stored.status, AccountStatus::Active);
|
||||
assert_eq!(
|
||||
stored.access_token.expose_secret(),
|
||||
new_access_token.as_str()
|
||||
);
|
||||
assert!(stored.next_refresh_at.is_some());
|
||||
assert_eq!(task.scheduled_timer_count().await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_refresh_task_should_skip_unauthorized_refresh_without_refresh_token() {
|
||||
let dir = tempfile::tempdir().expect("temp dir");
|
||||
let db = dir.path().join("token-refresh-unauthorized-no-rt.sqlite");
|
||||
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
|
||||
.await
|
||||
.expect("sqlite pool");
|
||||
let store = SqliteAccountStore::new(pool);
|
||||
let now = Utc::now();
|
||||
let old_access_token = test_jwt((now + Duration::minutes(10)).timestamp());
|
||||
store
|
||||
.insert(NewAccount {
|
||||
id: "acct-refresh-unauthorized-no-rt".to_string(),
|
||||
email: Some("unauthorized-no-rt@example.com".to_string()),
|
||||
account_id: Some("chatgpt-unauthorized-no-rt".to_string()),
|
||||
user_id: Some("user-unauthorized-no-rt".to_string()),
|
||||
label: None,
|
||||
plan_type: Some("plus".to_string()),
|
||||
access_token: SecretString::new(old_access_token.clone().into()),
|
||||
refresh_token: None,
|
||||
added_at: None,
|
||||
access_token_expires_at: Some(now + Duration::minutes(10)),
|
||||
status: AccountStatus::Expired,
|
||||
})
|
||||
.await
|
||||
.expect("account should be inserted");
|
||||
let refresher = CountingTokenRefresher::default();
|
||||
let task = codex_proxy_rs::upstream::accounts::token_refresh::RuntimeTokenRefreshService::new(
|
||||
store.clone(),
|
||||
RefreshPolicy {
|
||||
refresh_margin_seconds: 300,
|
||||
refresh_concurrency: 1,
|
||||
},
|
||||
refresher.clone(),
|
||||
);
|
||||
|
||||
task.trigger_account_refresh_now("acct-refresh-unauthorized-no-rt")
|
||||
.await
|
||||
.expect("unauthorized refresh should no-op without refresh token");
|
||||
let stored = store
|
||||
.get("acct-refresh-unauthorized-no-rt")
|
||||
.await
|
||||
.expect("account should load")
|
||||
.expect("account should exist");
|
||||
|
||||
assert_eq!(refresher.calls.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(stored.status, AccountStatus::Expired);
|
||||
assert_eq!(
|
||||
stored.access_token.expose_secret(),
|
||||
old_access_token.as_str()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -33,7 +33,7 @@ async fn openai_token_client_should_exchange_refresh_token_with_form_body() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_token_client_should_not_treat_refresh_token_reuse_as_banned() {
|
||||
async fn openai_token_client_should_treat_refresh_token_reuse_as_banned() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
@@ -47,17 +47,32 @@ async fn openai_token_client_should_not_treat_refresh_token_reuse_as_banned() {
|
||||
|
||||
let failure = client.refresh("refresh-secret").await.unwrap_err();
|
||||
|
||||
assert_eq!(failure, RefreshFailure::InvalidGrant);
|
||||
assert_eq!(failure, RefreshFailure::Banned);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_token_client_should_treat_explicit_banned_signal_as_banned() {
|
||||
async fn openai_token_client_should_not_treat_generic_banned_signal_as_banned() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(403).set_body_string("account is banned"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let client = test_token_client(&server);
|
||||
|
||||
let failure = client.refresh("refresh-secret").await.unwrap_err();
|
||||
|
||||
assert_eq!(failure, RefreshFailure::Transport);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_token_client_should_treat_deactivated_refresh_error_as_banned() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({
|
||||
"error": "access_denied",
|
||||
"error_description": "account is banned"
|
||||
"error_description": "account has been deactivated"
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
@@ -68,6 +83,51 @@ async fn openai_token_client_should_treat_explicit_banned_signal_as_banned() {
|
||||
assert_eq!(failure, RefreshFailure::Banned);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_token_client_should_retry_non_permanent_http_refresh_errors() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(400).set_body_string("account disabled"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let client = test_token_client(&server);
|
||||
|
||||
let failure = client.refresh("refresh-secret").await.unwrap_err();
|
||||
|
||||
assert_eq!(failure, RefreshFailure::Transport);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_token_client_should_treat_quota_refresh_error_as_temporary() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(400).set_body_string("quota exceeded"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let client = test_token_client(&server);
|
||||
|
||||
let failure = client.refresh("refresh-secret").await.unwrap_err();
|
||||
|
||||
assert_eq!(failure, RefreshFailure::Transport);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_token_client_should_treat_token_revoked_without_invalid_grant_as_temporary() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(400).set_body_string("token_revoked"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let client = test_token_client(&server);
|
||||
|
||||
let failure = client.refresh("refresh-secret").await.unwrap_err();
|
||||
|
||||
assert_eq!(failure, RefreshFailure::Transport);
|
||||
}
|
||||
|
||||
fn test_token_client(server: &MockServer) -> OpenAiTokenClient {
|
||||
OpenAiTokenClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
|
||||
+409
-115
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ArrowRight, Cat, Eye, EyeOff, KeyRound, Mail, Moon, Sun } from '@lucide/vue'
|
||||
import { gsap } from 'gsap'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
@@ -21,6 +22,8 @@ const password = shallowRef('')
|
||||
const passwordVisible = shallowRef(false)
|
||||
const passwordType = computed(() => (passwordVisible.value ? 'text' : 'password'))
|
||||
const passwordToggleLabel = computed(() => (passwordVisible.value ? '隐藏密码' : '显示密码'))
|
||||
const canSubmit = computed(() => !!username.value.trim() && !!password.value.trim())
|
||||
const submitDisabled = computed(() => authStore.loading || !canSubmit.value)
|
||||
const themeToggleLabel = computed(() =>
|
||||
effectiveTheme.value === 'dark' ? '切换浅色模式' : '切换暗黑模式',
|
||||
)
|
||||
@@ -30,7 +33,7 @@ function togglePasswordVisible() {
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!username.value.trim() || !password.value.trim()) {
|
||||
if (!canSubmit.value) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -43,12 +46,137 @@ async function handleSubmit() {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
// ── GSAP ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const stageRef = ref<HTMLElement | null>(null)
|
||||
let ctx: gsap.Context | undefined
|
||||
|
||||
onMounted(() => {
|
||||
if (!stageRef.value) return
|
||||
|
||||
ctx = gsap.context(() => {
|
||||
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } })
|
||||
|
||||
// Brand logo pop-in
|
||||
tl.from('.login-brand', { autoAlpha: 0, y: -14, duration: 0.55 })
|
||||
tl.from('.login-theme-toggle', { autoAlpha: 0, y: -14, duration: 0.4 }, '<0.08')
|
||||
|
||||
// Copy block cascades in from the left
|
||||
tl.from(
|
||||
'.login-copy::before',
|
||||
{ scaleX: 0, transformOrigin: 'left center', duration: 0.5 },
|
||||
'-=0.1',
|
||||
)
|
||||
tl.from('.login-copy-headline', { autoAlpha: 0, x: -28, duration: 0.56 }, '-=0.3')
|
||||
tl.from('.login-copy-lead', { autoAlpha: 0, x: -20, duration: 0.5 }, '-=0.32')
|
||||
tl.from(
|
||||
'.login-scope span',
|
||||
{
|
||||
autoAlpha: 0,
|
||||
y: 10,
|
||||
duration: 0.38,
|
||||
stagger: 0.08,
|
||||
},
|
||||
'-=0.26',
|
||||
)
|
||||
|
||||
// Form card rises from below
|
||||
tl.from(
|
||||
'.login-form',
|
||||
{
|
||||
autoAlpha: 0,
|
||||
y: 36,
|
||||
duration: 0.62,
|
||||
ease: 'power2.out',
|
||||
},
|
||||
'-=0.42',
|
||||
)
|
||||
|
||||
// Form internals cascade. Keep submit timing separate so the disabled
|
||||
// state settles before the call-to-action appears.
|
||||
tl.from(
|
||||
'.login-form-header, .login-error, .login-field-group',
|
||||
{
|
||||
autoAlpha: 0,
|
||||
y: 14,
|
||||
duration: 0.4,
|
||||
stagger: 0.07,
|
||||
clearProps: 'all',
|
||||
},
|
||||
'-=0.38',
|
||||
)
|
||||
tl.from(
|
||||
'.login-submit-slot',
|
||||
{
|
||||
autoAlpha: 0,
|
||||
y: 10,
|
||||
duration: 0.34,
|
||||
clearProps: 'all',
|
||||
},
|
||||
'-=0.12',
|
||||
)
|
||||
tl.from(
|
||||
'.login-form-footer',
|
||||
{
|
||||
autoAlpha: 0,
|
||||
y: 8,
|
||||
duration: 0.28,
|
||||
clearProps: 'all',
|
||||
},
|
||||
'-=0.16',
|
||||
)
|
||||
|
||||
// Floating orbs drift continuously
|
||||
gsap.to('.login-orb-a', {
|
||||
y: -24,
|
||||
x: 14,
|
||||
rotation: 8,
|
||||
duration: 7,
|
||||
repeat: -1,
|
||||
yoyo: true,
|
||||
ease: 'sine.inOut',
|
||||
})
|
||||
gsap.to('.login-orb-b', {
|
||||
y: 18,
|
||||
x: -18,
|
||||
rotation: -6,
|
||||
duration: 9,
|
||||
repeat: -1,
|
||||
yoyo: true,
|
||||
ease: 'sine.inOut',
|
||||
delay: 1.4,
|
||||
})
|
||||
gsap.to('.login-orb-c', {
|
||||
y: -16,
|
||||
x: 10,
|
||||
rotation: 5,
|
||||
duration: 11,
|
||||
repeat: -1,
|
||||
yoyo: true,
|
||||
ease: 'sine.inOut',
|
||||
delay: 2.8,
|
||||
})
|
||||
}, stageRef.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
ctx?.revert()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="login-page">
|
||||
<!-- Decorative background wash -->
|
||||
<div class="login-page-wash" aria-hidden="true" />
|
||||
|
||||
<!-- Floating orbs -->
|
||||
<div class="login-orbs" aria-hidden="true">
|
||||
<div class="login-orb login-orb-a" />
|
||||
<div class="login-orb login-orb-b" />
|
||||
<div class="login-orb login-orb-c" />
|
||||
</div>
|
||||
|
||||
<BaseButton
|
||||
icon-only
|
||||
variant="ghost"
|
||||
@@ -61,7 +189,7 @@ async function handleSubmit() {
|
||||
<Moon v-else :size="19" />
|
||||
</BaseButton>
|
||||
|
||||
<section class="login-stage" aria-label="Codex Proxy RS 登录">
|
||||
<section ref="stageRef" class="login-stage" aria-label="Codex Proxy RS 登录">
|
||||
<header class="login-brand">
|
||||
<span class="login-logo">
|
||||
<Cat :size="27" stroke-width="2" />
|
||||
@@ -73,8 +201,8 @@ async function handleSubmit() {
|
||||
</header>
|
||||
|
||||
<section class="login-copy" aria-labelledby="login-title">
|
||||
<h1 id="login-title">更轻的模型网关</h1>
|
||||
<p>安全的转发 OpenAI / Codex 请求, 风控指纹对齐,链路轻量安全</p>
|
||||
<h1 id="login-title" class="login-copy-headline">更轻的模型网关</h1>
|
||||
<p class="login-copy-lead">安全的转发 OpenAI / Codex 请求,风控指纹对齐,链路轻量安全</p>
|
||||
<div class="login-scope" aria-label="管理范围">
|
||||
<span>轻量部署</span>
|
||||
<span>安全转发</span>
|
||||
@@ -89,15 +217,18 @@ async function handleSubmit() {
|
||||
class="login-form"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<!-- Card inner glow layer -->
|
||||
<div class="login-form-glow-layer" aria-hidden="true" />
|
||||
|
||||
<header class="login-form-header">
|
||||
<h2>登录控制台</h2>
|
||||
<p>使用管理员账号继续管理网关。</p>
|
||||
<div>
|
||||
<h2>登录控制台</h2>
|
||||
<p>使用管理员账号继续管理网关。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="authStore.error" class="login-error">
|
||||
<p>
|
||||
{{ authStore.error }}
|
||||
</p>
|
||||
<p>{{ authStore.error }}</p>
|
||||
</div>
|
||||
|
||||
<label class="login-field-group">
|
||||
@@ -143,45 +274,56 @@ async function handleSubmit() {
|
||||
</BaseInput>
|
||||
</label>
|
||||
|
||||
<BaseButton
|
||||
size="lg"
|
||||
type="submit"
|
||||
class="login-submit"
|
||||
:loading="authStore.loading"
|
||||
:disabled="authStore.loading || !username.trim() || !password.trim()"
|
||||
>
|
||||
<span>{{ authStore.loading ? '登录中...' : '登录' }}</span>
|
||||
<ArrowRight v-if="!authStore.loading" :size="18" />
|
||||
</BaseButton>
|
||||
<div class="login-submit-slot">
|
||||
<BaseButton
|
||||
size="lg"
|
||||
type="submit"
|
||||
class="login-submit"
|
||||
:loading="authStore.loading"
|
||||
:disabled="submitDisabled"
|
||||
>
|
||||
<span>{{ authStore.loading ? '登录中...' : '登录' }}</span>
|
||||
<ArrowRight v-if="!authStore.loading" :size="18" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<footer class="login-form-footer">
|
||||
<span>Codex Proxy RS</span>
|
||||
<span>轻量安全</span>
|
||||
</footer>
|
||||
</BaseCard>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Design tokens (page-scoped overrides) ─────────────────────────────────── */
|
||||
.login-page {
|
||||
--login-bg-a: #f7fafc;
|
||||
--login-bg-b: #eef6ff;
|
||||
--login-bg-c: #f8fbf5;
|
||||
--login-grid-line: #0e17260a;
|
||||
--login-rail-line: #2563eb0d;
|
||||
--login-wash-line: #ffffff8f;
|
||||
--login-depth-x: 0px;
|
||||
--login-depth-y: 0px;
|
||||
--login-form-glass-a: #ffffff72;
|
||||
--login-form-glass-mid: #f6fbff48;
|
||||
--login-form-glass-b: #eef7f22e;
|
||||
--login-form-sheen: #ffffff36;
|
||||
--login-form-shadow: #0e17264a;
|
||||
--login-bg-a: #dfe7f0;
|
||||
--login-bg-b: #cbd9e9;
|
||||
--login-bg-c: #dce8e1;
|
||||
--login-grid-line: #0e172612;
|
||||
--login-rail-line: #2563eb16;
|
||||
--login-wash-line: #ffffff40;
|
||||
--login-form-glass-a: #ffffff8c;
|
||||
--login-form-glass-mid: #f4f8ff76;
|
||||
--login-form-glass-b: #e5f3ed68;
|
||||
--login-form-sheen: #ffffff92;
|
||||
--login-form-shadow: #0e172658;
|
||||
--login-form-glow: #5eead426;
|
||||
--login-form-highlight-a: #60a5fa2e;
|
||||
--login-form-highlight-b: #2dd4bf24;
|
||||
--login-input-bg: #edf2f8;
|
||||
--login-input-bg-hover: #f3f7fb;
|
||||
--login-control-height: 46px;
|
||||
--login-orb-a: #3b82f6;
|
||||
--login-orb-b: #0f9f9a;
|
||||
--login-orb-c: #a78bfa;
|
||||
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
height: 100dvh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
overflow: hidden;
|
||||
color: var(--cp-text-primary);
|
||||
background:
|
||||
linear-gradient(90deg, var(--login-grid-line) 1px, transparent 1px),
|
||||
@@ -194,22 +336,28 @@ async function handleSubmit() {
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark'] .login-page) {
|
||||
--login-bg-a: #08101b;
|
||||
--login-bg-b: #101827;
|
||||
--login-bg-c: #0d1819;
|
||||
--login-bg-a: #07101b;
|
||||
--login-bg-b: #0e1626;
|
||||
--login-bg-c: #0c1719;
|
||||
--login-grid-line: #e6edf70a;
|
||||
--login-rail-line: #6ea8ff12;
|
||||
--login-wash-line: #ffffff12;
|
||||
--login-form-glass-a: #31466536;
|
||||
--login-form-glass-mid: #14274328;
|
||||
--login-form-glass-b: #06132634;
|
||||
--login-form-sheen: #ffffff10;
|
||||
--login-form-shadow: #00000078;
|
||||
--login-form-glass-a: #31466538;
|
||||
--login-form-glass-mid: #14274330;
|
||||
--login-form-glass-b: #06132638;
|
||||
--login-form-sheen: #ffffff12;
|
||||
--login-form-shadow: #00000080;
|
||||
--login-form-glow: #2dd4bf18;
|
||||
--login-form-highlight-a: #6ea8ff20;
|
||||
--login-form-highlight-b: #2dd4bf18;
|
||||
--login-input-bg: #182437;
|
||||
--login-input-bg-hover: #1d2d43;
|
||||
--login-orb-a: #3b82f6;
|
||||
--login-orb-b: #2dd4bf;
|
||||
--login-orb-c: #a78bfa;
|
||||
}
|
||||
|
||||
/* ── Page wash ─────────────────────────────────────────────────────────────── */
|
||||
.login-page::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -229,8 +377,8 @@ async function handleSubmit() {
|
||||
background:
|
||||
conic-gradient(from 210deg at 48% 42%, #2563eb18, #0f9f9a16, #f59e0b12, #2563eb18),
|
||||
linear-gradient(90deg, transparent, #ffffff54, transparent);
|
||||
filter: blur(38px);
|
||||
opacity: 0.66;
|
||||
filter: blur(42px);
|
||||
opacity: 0.32;
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
|
||||
@@ -241,6 +389,70 @@ async function handleSubmit() {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
/* ── Floating orbs ─────────────────────────────────────────────────────────── */
|
||||
.login-orbs {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(62px);
|
||||
opacity: 0.14;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
:global(html[data-theme='dark'] .login-orb) {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.login-orb-a {
|
||||
width: 460px;
|
||||
height: 460px;
|
||||
background: var(--login-orb-a);
|
||||
top: -12%;
|
||||
right: -6%;
|
||||
}
|
||||
|
||||
.login-orb-b {
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
background: var(--login-orb-b);
|
||||
bottom: -8%;
|
||||
left: 4%;
|
||||
}
|
||||
|
||||
.login-orb-c {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
background: var(--login-orb-c);
|
||||
top: 42%;
|
||||
left: 34%;
|
||||
}
|
||||
|
||||
/* ── Theme toggle ──────────────────────────────────────────────────────────── */
|
||||
.login-theme-toggle {
|
||||
position: fixed;
|
||||
top: clamp(24px, 4vh, 42px);
|
||||
right: clamp(20px, 4vw, 56px);
|
||||
z-index: 10;
|
||||
color: var(--cp-text-secondary);
|
||||
background: color-mix(in srgb, var(--cp-bg-surface) 60%, transparent);
|
||||
box-shadow: 0 18px 34px -28px #0e1726;
|
||||
backdrop-filter: blur(12px) saturate(1.3);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(1.3);
|
||||
}
|
||||
|
||||
.login-theme-toggle:hover {
|
||||
color: var(--cp-text-primary);
|
||||
background: color-mix(in srgb, var(--cp-bg-surface) 78%, transparent);
|
||||
}
|
||||
|
||||
/* ── Stage layout ──────────────────────────────────────────────────────────── */
|
||||
.login-stage {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -254,11 +466,12 @@ async function handleSubmit() {
|
||||
gap: clamp(26px, 4.8vh, 46px);
|
||||
}
|
||||
|
||||
/* ── Brand ─────────────────────────────────────────────────────────────────── */
|
||||
.login-brand {
|
||||
position: fixed;
|
||||
top: clamp(22px, 4vh, 40px);
|
||||
left: clamp(20px, 4vw, 56px);
|
||||
z-index: 2;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
@@ -275,7 +488,9 @@ async function handleSubmit() {
|
||||
border-radius: var(--cp-icon-button-radius);
|
||||
background: var(--cp-bg-dark);
|
||||
color: var(--cp-white);
|
||||
box-shadow: 0 14px 28px -22px #0e1726;
|
||||
box-shadow:
|
||||
0 14px 28px -22px #0e1726,
|
||||
inset 0 1px 0 #ffffff20;
|
||||
}
|
||||
|
||||
.login-brand-text {
|
||||
@@ -298,57 +513,42 @@ async function handleSubmit() {
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.login-theme-toggle {
|
||||
position: fixed;
|
||||
top: clamp(24px, 4vh, 42px);
|
||||
right: clamp(20px, 4vw, 56px);
|
||||
z-index: 2;
|
||||
color: var(--cp-text-secondary);
|
||||
background: color-mix(in srgb, var(--cp-bg-surface) 54%, transparent);
|
||||
box-shadow: 0 18px 34px -28px #0e1726;
|
||||
backdrop-filter: blur(10px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(1.2);
|
||||
}
|
||||
|
||||
.login-theme-toggle:hover {
|
||||
color: var(--cp-text-primary);
|
||||
background: color-mix(in srgb, var(--cp-bg-surface) 72%, transparent);
|
||||
}
|
||||
|
||||
/* ── Copy block ────────────────────────────────────────────────────────────── */
|
||||
.login-copy {
|
||||
display: grid;
|
||||
gap: 13px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.login-copy::before {
|
||||
width: 20px;
|
||||
height: 2px;
|
||||
.login-copy-headline::before {
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 2.5px;
|
||||
margin-bottom: 12px;
|
||||
content: '';
|
||||
border-radius: var(--cp-radius-circle);
|
||||
background: linear-gradient(90deg, #2563eb, #0f9f9a);
|
||||
opacity: 0.72;
|
||||
opacity: 0.8;
|
||||
position: relative;
|
||||
left: 72px;
|
||||
top: 2px;
|
||||
left: 75px;
|
||||
}
|
||||
|
||||
.login-copy h1 {
|
||||
max-width: 12em;
|
||||
.login-copy-headline {
|
||||
margin: 0;
|
||||
color: var(--cp-text-primary);
|
||||
font-size: clamp(34px, 9vw, 48px);
|
||||
font-weight: 760;
|
||||
line-height: 1.04;
|
||||
letter-spacing: 0;
|
||||
font-size: clamp(36px, 9vw, 52px);
|
||||
font-weight: 800;
|
||||
line-height: 1.06;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.login-copy p {
|
||||
max-width: 40rem;
|
||||
.login-copy-lead {
|
||||
max-width: 38rem;
|
||||
margin: 0;
|
||||
color: var(--cp-text-secondary);
|
||||
font-size: clamp(13px, 3.5vw, 15px);
|
||||
font-weight: 580;
|
||||
line-height: 1.55;
|
||||
font-weight: 560;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.login-scope {
|
||||
@@ -384,43 +584,46 @@ async function handleSubmit() {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
/* ── Form card ─────────────────────────────────────────────────────────────── */
|
||||
.login-form {
|
||||
--cp-bg-surface: transparent;
|
||||
--cp-input-current-bg: var(--login-input-bg);
|
||||
--cp-input-current-bg-hover: var(--login-input-bg-hover);
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
gap: 23px;
|
||||
padding: clamp(32px, 7vw, 42px);
|
||||
gap: 22px;
|
||||
padding: clamp(28px, 6vw, 40px);
|
||||
background-color: transparent;
|
||||
background:
|
||||
radial-gradient(circle at 16% 0%, var(--login-form-sheen) 0%, transparent 34%),
|
||||
radial-gradient(circle at 96% 8%, var(--login-form-highlight-b) 0%, transparent 36%),
|
||||
radial-gradient(circle at 14% 0%, var(--login-form-sheen) 0%, transparent 36%),
|
||||
radial-gradient(circle at 92% 10%, var(--login-form-highlight-b) 0%, transparent 38%),
|
||||
linear-gradient(
|
||||
145deg,
|
||||
148deg,
|
||||
var(--login-form-glass-a),
|
||||
var(--login-form-glass-mid) 48%,
|
||||
var(--login-form-glass-mid) 50%,
|
||||
var(--login-form-glass-b)
|
||||
);
|
||||
box-shadow:
|
||||
0 44px 96px -48px var(--login-form-shadow),
|
||||
0 18px 42px -36px var(--login-form-glow),
|
||||
0 24px 64px -58px var(--login-form-highlight-a) inset,
|
||||
0 -18px 42px -40px var(--login-form-highlight-b) inset;
|
||||
backdrop-filter: blur(9px) saturate(1.45);
|
||||
-webkit-backdrop-filter: blur(9px) saturate(1.45);
|
||||
0 30px 72px -34px var(--login-form-shadow),
|
||||
0 14px 34px -26px var(--login-form-glow),
|
||||
0 28px 64px -58px var(--login-form-highlight-a) inset,
|
||||
0 -20px 42px -40px var(--login-form-highlight-b) inset;
|
||||
backdrop-filter: blur(30px) saturate(1.55);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(1.55);
|
||||
}
|
||||
|
||||
.login-form::before {
|
||||
.login-form-glow-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
content: '';
|
||||
border-radius: inherit;
|
||||
background:
|
||||
linear-gradient(120deg, var(--login-form-sheen), transparent 32%),
|
||||
radial-gradient(circle at 72% 0%, var(--login-form-highlight-a), transparent 36%);
|
||||
opacity: 0.12;
|
||||
linear-gradient(118deg, var(--login-form-sheen), transparent 34%),
|
||||
radial-gradient(circle at 74% 2%, var(--login-form-highlight-a), transparent 38%);
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
.login-form > * {
|
||||
@@ -428,32 +631,41 @@ async function handleSubmit() {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── Form header ───────────────────────────────────────────────────────────── */
|
||||
.login-form-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.login-form-header > div {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.login-form-header h2 {
|
||||
margin: 0;
|
||||
color: var(--cp-text-primary);
|
||||
font-size: clamp(31px, 7vw, 36px);
|
||||
font-size: clamp(26px, 6vw, 32px);
|
||||
font-weight: 800;
|
||||
line-height: 1.12;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.login-form-header p {
|
||||
margin: 0;
|
||||
color: var(--cp-text-secondary);
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 560;
|
||||
line-height: 1.55;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Error banner ──────────────────────────────────────────────────────────── */
|
||||
.login-error {
|
||||
border-radius: var(--cp-input-radius-base);
|
||||
background: var(--cp-danger-bg);
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--cp-danger-border);
|
||||
}
|
||||
|
||||
.login-error p {
|
||||
@@ -463,6 +675,7 @@ async function handleSubmit() {
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
/* ── Field groups ──────────────────────────────────────────────────────────── */
|
||||
.login-field-group {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -488,19 +701,94 @@ async function handleSubmit() {
|
||||
color: var(--cp-text-primary);
|
||||
}
|
||||
|
||||
.login-submit {
|
||||
width: 100%;
|
||||
/* ── Submit button ─────────────────────────────────────────────────────────── */
|
||||
.login-submit-slot {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.login-submit {
|
||||
width: 100%;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--cp-info) 0%,
|
||||
color-mix(in srgb, var(--cp-info) 70%, #0f9f9a) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 8px 24px -12px color-mix(in srgb, var(--cp-info) 60%, transparent),
|
||||
inset 0 1px 0 #ffffff28;
|
||||
transition:
|
||||
color 0.18s ease,
|
||||
background 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.14s ease;
|
||||
}
|
||||
|
||||
.login-submit:not(:disabled):hover {
|
||||
box-shadow:
|
||||
0 12px 30px -10px color-mix(in srgb, var(--cp-info) 70%, transparent),
|
||||
inset 0 1px 0 #ffffff36;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.login-submit:not(:disabled):active {
|
||||
transform: translateY(0px);
|
||||
box-shadow:
|
||||
0 4px 14px -8px color-mix(in srgb, var(--cp-info) 50%, transparent),
|
||||
inset 0 1px 0 #ffffff18;
|
||||
}
|
||||
|
||||
.login-submit:disabled {
|
||||
color: var(--cp-disabled-text);
|
||||
background: var(--cp-disabled-bg);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ── Form footer ───────────────────────────────────────────────────────────── */
|
||||
.login-form-footer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.login-form-footer span {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding: 0 10px;
|
||||
color: var(--cp-text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.login-form-footer span:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.login-form-footer span + span::before {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
height: 9px;
|
||||
content: '';
|
||||
background: var(--cp-default-border);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
/* ── Responsive: tablet+ ───────────────────────────────────────────────────── */
|
||||
@media (min-width: 720px) {
|
||||
.login-stage {
|
||||
width: min(calc(100% - 80px), 640px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Responsive: desktop ───────────────────────────────────────────────────── */
|
||||
@media (min-width: 1100px) {
|
||||
.login-stage {
|
||||
width: min(calc(100% - 112px), 1280px);
|
||||
width: min(calc(100% - 112px), 1320px);
|
||||
grid-template-columns: minmax(0, 1fr) minmax(452px, 500px);
|
||||
grid-template-areas: 'copy form';
|
||||
column-gap: clamp(82px, 9vw, 148px);
|
||||
@@ -517,18 +805,15 @@ async function handleSubmit() {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.login-copy h1 {
|
||||
max-width: 10.5em;
|
||||
font-size: clamp(44px, 3.4vw, 54px);
|
||||
.login-copy-headline {
|
||||
max-width: 8em;
|
||||
font-size: clamp(46px, 3.6vw, 58px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Responsive: small mobile ──────────────────────────────────────────────── */
|
||||
@media (max-width: 520px) {
|
||||
.login-page {
|
||||
background:
|
||||
linear-gradient(90deg, var(--login-grid-line) 1px, transparent 1px),
|
||||
linear-gradient(var(--login-grid-line) 1px, transparent 1px),
|
||||
linear-gradient(135deg, var(--login-bg-a) 0%, var(--login-bg-b) 52%, var(--login-bg-c) 100%);
|
||||
background-size:
|
||||
48px 48px,
|
||||
48px 48px,
|
||||
@@ -536,7 +821,7 @@ async function handleSubmit() {
|
||||
}
|
||||
|
||||
.login-stage {
|
||||
gap: clamp(18px, 3.4vh, 28px);
|
||||
gap: clamp(18px, 3.4vh, 26px);
|
||||
padding: clamp(18px, 3.6vh, 32px) 0;
|
||||
}
|
||||
|
||||
@@ -549,11 +834,20 @@ async function handleSubmit() {
|
||||
top: 18px;
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
padding: 24px 22px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Reduced motion ────────────────────────────────────────────────────────── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.login-page-wash {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.login-orb {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user