From 947e43677ed069476182817dd9fcaea9607b3191 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Mon, 1 Jun 2026 15:29:39 +0800 Subject: [PATCH] {"schema":"decodex/commit/1","summary":"Refine Decodex App account login status","authority":"manual"} --- .../Sources/DecodexApp/AccountPanelView.swift | 62 +++++++++--- .../Sources/DecodexApp/LoginSheetView.swift | 37 ++++++- .../Sources/DecodexApp/Models.swift | 76 +++++++++++++-- .../DecodexAppTests/AccountModelTests.swift | 97 +++++++++++++++++++ apps/decodex/src/accounts.rs | 83 ++++++++++++++++ 5 files changed, 331 insertions(+), 24 deletions(-) create mode 100644 apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift index eb0416b0..a73b0202 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift @@ -741,16 +741,18 @@ struct AccountRowView: View { .truncationMode(.middle) .layoutPriority(1) - Text("·") - .font(PanelFont.accountDetail) - .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.62)) - .fixedSize(horizontal: true, vertical: false) + if let capacityLabel = account.currentCapacityLabel { + Text("·") + .font(PanelFont.accountDetail) + .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.62)) + .fixedSize(horizontal: true, vertical: false) - Text(account.capacityLabel) - .font(PanelFont.accountDetail) - .foregroundStyle(PanelPalette.secondaryText(colorScheme)) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) + Text(capacityLabel) + .font(PanelFont.accountDetail) + .foregroundStyle(PanelPalette.secondaryText(colorScheme)) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } if let healthLabel = account.compactHealthLabel { Text("·") @@ -777,7 +779,7 @@ struct AccountRowView: View { isPrimary: true, size: 21, action: login, - help: "Login account" + help: loginHelp ) } else { PanelIconButtonView( @@ -850,7 +852,7 @@ struct AccountRowView: View { return "Restore balanced run routing" } if account.needsLogin { - return "Login before routing runs" + return "Sign in again before routing runs" } if account.disabled { return "Disabled account cannot route runs" @@ -858,6 +860,14 @@ struct AccountRowView: View { return "Route Decodex runs here" } + + private var loginHelp: String { + if account.recoveryActionKind == .login { + return "Refresh token was rejected; sign in again" + } + + return "Login account" + } } struct AccountRunSummaryView: View { @@ -2359,7 +2369,7 @@ private enum AccountPrivacy { static let visibleValue = "visible" } -private enum AccountDisplay { +enum AccountDisplay { static let randomNames = [ "Alex", "Avery", @@ -2447,7 +2457,7 @@ private enum AccountDisplay { return "\(local)\(domain)" } - return "\(local.prefix(3))...\(local.suffix(3))\(domain)" + return "\(local.prefix(3))...\(compactLocalSuffix(local))\(domain)" } static func compactIdentity(_ value: String) -> String { @@ -2469,6 +2479,17 @@ private enum AccountDisplay { return text } + private static func compactLocalSuffix(_ local: String) -> String { + if let separator = local.lastIndex(of: ".") { + let segment = String(local[local.index(after: separator)...]) + if (2...4).contains(segment.count), segment.allSatisfy(\.isLetter) { + return segment + } + } + + return String(local.suffix(3)) + } + private static func identityHash(_ value: String) -> UInt32 { var hash: UInt32 = 2_166_136_261 for unit in value.utf16 { @@ -2974,13 +2995,24 @@ private extension CodexAccount { return "Limited" } + switch recoveryActionKind { + case .login: + return "Re-login" + case .refresh: + return "Refresh needed" + case .retryProbe: + return "Probe failed" + case .none: + break + } + switch status { case "available": return nil case "usage_limited": return "Limited" case "probe_failed": - return "-" + return "Probe failed" case "expired": return "Refresh needed" case "disabled": @@ -2988,7 +3020,7 @@ private extension CodexAccount { case "cooldown": return "Cooling" case "unusable": - return "Needs login" + return "Needs attention" default: let label = status.replacingOccurrences(of: "_", with: " ").capitalized return label.isEmpty ? nil : label diff --git a/apps/decodex-app/Sources/DecodexApp/LoginSheetView.swift b/apps/decodex-app/Sources/DecodexApp/LoginSheetView.swift index 14e3e154..de42191e 100644 --- a/apps/decodex-app/Sources/DecodexApp/LoginSheetView.swift +++ b/apps/decodex-app/Sources/DecodexApp/LoginSheetView.swift @@ -305,9 +305,22 @@ private struct LoginCodeBoxesView: View { Text(character(at: index)) .font(LoginFont.code) .monospacedDigit() - .foregroundStyle(LoginPalette.primaryText(colorScheme)) - .frame(width: 22, height: 30) - .modernGlassSurface(cornerRadius: 7, depth: .control) + .foregroundStyle(code.isEmpty ? LoginPalette.secondaryText(colorScheme).opacity(0.42) : LoginPalette.primaryText(colorScheme)) + .frame(width: 23, height: 31) + .background { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(LoginPalette.codeBoxFill(colorScheme)) + } + .overlay { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .strokeBorder(LoginPalette.codeBoxStroke(colorScheme), lineWidth: 0.75) + .allowsHitTesting(false) + } + .shadow( + color: LoginPalette.codeBoxShadow(colorScheme), + radius: colorScheme == .dark ? 2.5 : 1.4, + y: 0.7 + ) } } .frame(maxWidth: .infinity, alignment: .center) @@ -478,4 +491,22 @@ private enum LoginPalette { ? Color(red: 0.86, green: 0.93, blue: 1) : Color(red: 0.13, green: 0.32, blue: 0.52) } + + static func codeBoxFill(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.08, green: 0.1, blue: 0.14).opacity(0.72) + : Color(red: 0.96, green: 0.975, blue: 1).opacity(0.92) + } + + static func codeBoxStroke(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color.white.opacity(0.16) + : Color(red: 0.48, green: 0.55, blue: 0.64).opacity(0.3) + } + + static func codeBoxShadow(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color.black.opacity(0.22) + : Color(red: 0.24, green: 0.32, blue: 0.42).opacity(0.08) + } } diff --git a/apps/decodex-app/Sources/DecodexApp/Models.swift b/apps/decodex-app/Sources/DecodexApp/Models.swift index f4517f56..2a5ed442 100644 --- a/apps/decodex-app/Sources/DecodexApp/Models.swift +++ b/apps/decodex-app/Sources/DecodexApp/Models.swift @@ -126,6 +126,7 @@ struct CodexAccount: Decodable, Identifiable, Equatable { let note: String? let planType: String? let capacityMultiplier: Int? + let recoveryAction: String? let refreshStatus: String? let checkedAtUnixEpoch: Int? let primaryWindowSeconds: Int? @@ -159,15 +160,15 @@ struct CodexAccount: Decodable, Identifiable, Equatable { } var needsLogin: Bool { - status == "unusable" || status == "expired" || !refreshTokenPresent + recoveryActionKind == .login } var canUseInCodex: Bool { - !disabled && !needsLogin + !disabled && recoveryActionKind != .login } var canRouteRuns: Bool { - !disabled && !needsLogin + !disabled && recoveryActionKind != .login } var statusLabel: String { @@ -180,6 +181,14 @@ struct CodexAccount: Decodable, Identifiable, Equatable { if selected { return "Runs routed" } + switch recoveryActionKind { + case .login: + return "Re-login required" + case .retryProbe: + return "Probe failed" + case .refresh, .none: + break + } switch status { case "available": return "Ready" @@ -188,7 +197,7 @@ struct CodexAccount: Decodable, Identifiable, Equatable { case "expired": return "Refresh needed" case "disabled": return "Disabled" case "cooldown": return "Cooling" - case "unusable": return "Needs login" + case "unusable": return recoveryActionKind == .login ? "Re-login required" : "Needs attention" default: return status.replacingOccurrences(of: "_", with: " ").capitalized } } @@ -206,10 +215,18 @@ struct CodexAccount: Decodable, Identifiable, Equatable { if selected { return .selected } + switch recoveryActionKind { + case .login: + return .danger + case .refresh, .retryProbe: + return .warning + case .none: + break + } switch status { case "available": return .ready - case "cooldown": return .warning - case "expired", "unusable", "disabled": return .danger + case "cooldown", "expired", "probe_failed": return .warning + case "unusable", "disabled": return .danger default: return .neutral } } @@ -222,6 +239,17 @@ struct CodexAccount: Decodable, Identifiable, Equatable { "\(capacityWeight)x" } + var currentCapacityLabel: String? { + guard status == "available" || status == "usage_limited" else { + return nil + } + guard checkedAtUnixEpoch != nil || hasUsageWindowData else { + return nil + } + + return capacityLabel + } + var hasUsageWindowData: Bool { primaryRemainingPercent != nil || secondaryRemainingPercent != nil } @@ -276,6 +304,7 @@ struct CodexAccount: Decodable, Identifiable, Equatable { note: note, planType: planType, capacityMultiplier: capacityMultiplier, + recoveryAction: recoveryAction, refreshStatus: refreshStatus, checkedAtUnixEpoch: checkedAtUnixEpoch, primaryWindowSeconds: primaryWindowSeconds, @@ -312,6 +341,7 @@ struct CodexAccount: Decodable, Identifiable, Equatable { case note case planType = "plan_type" case capacityMultiplier = "capacity_multiplier" + case recoveryAction = "recovery_action" case refreshStatus = "refresh_status" case checkedAtUnixEpoch = "checked_at_unix_epoch" case primaryWindowSeconds = "primary_window_seconds" @@ -340,6 +370,33 @@ struct CodexAccount: Decodable, Identifiable, Equatable { return 1 } + + var recoveryActionKind: AccountRecoveryAction { + if let recoveryAction = AccountRecoveryAction(rawValue: normalized(recoveryAction)) { + return recoveryAction + } + if !refreshTokenPresent { + return .login + } + if normalized(refreshStatus) == "failed" { + let noteText = normalized(note) + return noteText.contains("401") || noteText.contains("unauthorized") ? .login : .retryProbe + } + switch normalized(status) { + case "expired": + return .refresh + case "unusable": + return .login + case "probe_failed": + return .retryProbe + default: + return .none + } + } + + private func normalized(_ value: String?) -> String { + value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + } } enum AccountTone { @@ -351,6 +408,13 @@ enum AccountTone { case neutral } +enum AccountRecoveryAction: String { + case none + case refresh + case login + case retryProbe = "retry_probe" +} + enum UsageWindowLabel { static func make(seconds: Int?) -> String { guard let seconds, seconds > 0 else { diff --git a/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift b/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift new file mode 100644 index 00000000..0849649d --- /dev/null +++ b/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift @@ -0,0 +1,97 @@ +@testable import DecodexApp +import XCTest + +final class AccountModelTests: XCTestCase { + func testRefresh401RequiresReloginAndHidesCapacity() { + let account = makeAccount( + status: "unusable", + recoveryAction: "login", + refreshStatus: "failed", + checkedAtUnixEpoch: 1_800_000_000, + primaryRemainingPercent: 89 + ) + + XCTAssertTrue(account.needsLogin) + XCTAssertFalse(account.canRouteRuns) + XCTAssertEqual(account.statusLabel, "Re-login required") + XCTAssertNil(account.currentCapacityLabel) + } + + func testExpiredAccountCanRefreshButDoesNotShowCapacity() { + let account = makeAccount( + status: "expired", + recoveryAction: "refresh", + checkedAtUnixEpoch: 1_800_000_000, + primaryRemainingPercent: 89 + ) + + XCTAssertFalse(account.needsLogin) + XCTAssertTrue(account.canRouteRuns) + XCTAssertEqual(account.statusLabel, "Refresh needed") + XCTAssertNil(account.currentCapacityLabel) + } + + func testAvailableMeasuredAccountShowsCapacity() { + let account = makeAccount( + status: "available", + planType: "pro", + checkedAtUnixEpoch: 1_800_000_000, + primaryRemainingPercent: 89 + ) + + XCTAssertEqual(account.currentCapacityLabel, "20x") + } + + func testCompactEmailKeepsDottedLocalSuffixesConsistent() { + XCTAssertEqual(AccountDisplay.compactEmail("aurevoirxavier@gmail.com"), "aur...ier@gmail.com") + XCTAssertEqual(AccountDisplay.compactEmail("aurevoirxavier.us@gmail.com"), "aur...us@gmail.com") + XCTAssertEqual(AccountDisplay.compactEmail("aurevoirxavier.jp@gmail.com"), "aur...jp@gmail.com") + XCTAssertEqual(AccountDisplay.compactEmail("aurevoirxavier.hk@gmail.com"), "aur...hk@gmail.com") + XCTAssertEqual(AccountDisplay.compactEmail("xavier.lau@helixbox.ai"), "xav...lau@helixbox.ai") + } + + private func makeAccount( + status: String, + recoveryAction: String? = nil, + refreshStatus: String? = nil, + planType: String? = nil, + checkedAtUnixEpoch: Int? = nil, + primaryRemainingPercent: Int? = nil + ) -> CodexAccount { + CodexAccount( + accountFingerprint: "...123456", + email: "copy@example.com", + selector: "copy@example.com", + randomName: nil, + randomNameKey: nil, + randomNameOffset: nil, + status: status, + selected: false, + codexActive: false, + disabled: false, + refreshTokenPresent: true, + accessTokenExpiresAtUnixEpoch: nil, + lastSelectedAtUnixEpoch: nil, + cooldownUntilUnixEpoch: nil, + note: nil, + planType: planType, + capacityMultiplier: nil, + recoveryAction: recoveryAction, + refreshStatus: refreshStatus, + checkedAtUnixEpoch: checkedAtUnixEpoch, + primaryWindowSeconds: nil, + primaryRemainingPercent: primaryRemainingPercent, + primaryResetsAtUnixEpoch: nil, + secondaryWindowSeconds: nil, + secondaryRemainingPercent: nil, + secondaryResetsAtUnixEpoch: nil, + creditsHasCredits: nil, + creditsUnlimited: nil, + creditsBalance: nil, + rateLimitReachedType: nil, + sevenDayUsedPercent: nil, + sevenDayDailyAveragePercent: nil, + usageRecords: nil + ) + } +} diff --git a/apps/decodex/src/accounts.rs b/apps/decodex/src/accounts.rs index bc5494a9..5daf8eaa 100644 --- a/apps/decodex/src/accounts.rs +++ b/apps/decodex/src/accounts.rs @@ -617,6 +617,7 @@ pub(crate) struct AccountSummary { pub(crate) note: Option, pub(crate) plan_type: Option, pub(crate) capacity_multiplier: i64, + pub(crate) recovery_action: Option, pub(crate) refresh_status: Option, pub(crate) checked_at_unix_epoch: Option, pub(crate) primary_window_seconds: Option, @@ -657,6 +658,14 @@ impl AccountSummary { } self.note.clone_from(&summary.note); + + self.recovery_action = account_recovery_action( + self.status.as_str(), + self.refresh_token_present, + self.refresh_status.as_deref(), + self.note.as_deref(), + ); + self.apply_usage_estimate(); } @@ -1070,6 +1079,12 @@ impl AccountPoolRecord { let random_name_seed = random_name_seed_for(account_fingerprint.as_str(), self.email()); let random_name_key = random_name_key(&random_name_seed); let random_name_offset = name_offsets.get(&random_name_key).copied().unwrap_or_default(); + let recovery_action = account_recovery_action( + status, + refresh_token_present, + None, + Some("local account pool"), + ); AccountSummary { account_fingerprint, @@ -1090,6 +1105,7 @@ impl AccountPoolRecord { note: Some(String::from("local account pool")), plan_type: None, capacity_multiplier: DEFAULT_ACCOUNT_CAPACITY_MULTIPLIER, + recovery_action, refresh_status: None, checked_at_unix_epoch: None, primary_window_seconds: None, @@ -1694,6 +1710,39 @@ fn account_capacity_multiplier(plan_type: Option<&str>) -> i64 { } } +fn account_recovery_action( + status: &str, + refresh_token_present: bool, + refresh_status: Option<&str>, + note: Option<&str>, +) -> Option { + let status = status.trim().to_ascii_lowercase(); + let refresh_status = refresh_status.unwrap_or_default().trim().to_ascii_lowercase(); + + if status == "disabled" || status == "cooldown" { + return None; + } + if !refresh_token_present { + return Some(String::from("login")); + } + if refresh_status == "failed" { + let note = note.unwrap_or_default().to_ascii_lowercase(); + + if note.contains("401") || note.contains("unauthorized") { + return Some(String::from("login")); + } + + return Some(String::from("retry_probe")); + } + + match status.as_str() { + "expired" => Some(String::from("refresh")), + "unusable" => Some(String::from("login")), + "probe_failed" => Some(String::from("retry_probe")), + _ => None, + } +} + fn normalized_account_capacity_multiplier(value: i64) -> i64 { value.max(DEFAULT_ACCOUNT_CAPACITY_MULTIPLIER) } @@ -2084,10 +2133,44 @@ mod tests { assert_eq!(response.accounts[0].credits_balance.as_deref(), Some("9.99")); assert_eq!(response.accounts[0].seven_day_used_percent, Some(9)); assert_eq!(response.accounts[0].capacity_multiplier, 20); + assert_eq!(response.accounts[0].recovery_action, None); assert_close(response.accounts[0].seven_day_daily_average_percent, 9.0 / 7.0); } + #[test] + fn usage_summary_marks_refresh_401_as_login_recovery() { + let temp_dir = TempDir::new().expect("temp dir should create"); + let store = AccountStore::new( + temp_dir.path().join("accounts.jsonl"), + temp_dir.path().join("config.toml"), + ); + + store + .save_records(&[account_record( + "copy@example.com", + "acct_123456", + "header.eyJleHAiOjQxMDI0NDQ4MDB9.sig", + "refresh-secret", + )]) + .expect("records should save"); + + let mut response = store.list().expect("account list should load"); + + response.apply_usage_summaries(&[CodexAccountActivitySummary { + account_fingerprint: String::from("...123456"), + email: Some(String::from("copy@example.com")), + status: String::from("unusable"), + refresh_status: String::from("failed"), + note: Some(String::from( + "usage probe failed: Codex account `copy@example.com` token refresh failed with HTTP 401 Unauthorized.", + )), + ..CodexAccountActivitySummary::default() + }]); + + assert_eq!(response.accounts[0].recovery_action.as_deref(), Some("login")); + } + #[test] fn usage_records_and_pool_estimate_use_seven_day_window() { let temp_dir = TempDir::new().expect("temp dir should create");