diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt index 358e407de..53044aad8 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt @@ -86,7 +86,19 @@ fun App(deepLinkUri: String? = null) { ) { ApplyAndroidSystemBars(state.isDarkTheme) - if (state.showRateLimitDialog && state.rateLimitInfo != null) { + // Suppress the rate-limit dialog while the user is on the auth + // screen. They already accepted the prompt and are mid-sign-in; + // re-emitting the same dialog over the auth UI is noise that + // also blocks them from finishing the device-flow steps. Also + // flush any pending flag set by background API calls during + // auth, so it doesn't ghost back when the user returns home. + val onAuthScreen = currentScreen is GithubStoreGraph.AuthenticationScreen + LaunchedEffect(onAuthScreen, state.showRateLimitDialog) { + if (onAuthScreen && state.showRateLimitDialog) { + viewModel.onAction(MainAction.DismissRateLimitDialog) + } + } + if (state.showRateLimitDialog && state.rateLimitInfo != null && !onAuthScreen) { RateLimitDialog( rateLimitInfo = state.rateLimitInfo!!, isAuthenticated = state.isLoggedIn, diff --git a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt index 5b3751321..04201050a 100644 --- a/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt +++ b/feature/auth/presentation/src/commonMain/kotlin/zed/rainxch/auth/presentation/AuthenticationViewModel.kt @@ -422,9 +422,28 @@ class AuthenticationViewModel( } if (outcome.path != authPath) { - logger.debug("Auth path escalated from $authPath to ${outcome.path}") - authPath = outcome.path - savedStateHandle[KEY_AUTH_PATH] = authPath.name + // Mid-session transitions are strictly one-way: only the + // Backend → Direct escalation that AuthenticationRepositoryImpl + // performs on infrastructure errors (timeouts, connect/socket + // failures, 5xx) is legitimate. Direct → Backend is not a path + // the repository ever returns; treat any such outcome as a + // defensive no-op rather than silently flipping back to a + // probably-broken Backend mid-flow. The infrastructure-failure + // gate itself lives at the repository (single source of + // truth — see `pollDeviceTokenOnce` and + // `Throwable.isAuthInfrastructureError()`); the VM only + // enforces direction. + val isLegalEscalation = + authPath == AuthPath.Backend && outcome.path == AuthPath.Direct + if (isLegalEscalation) { + logger.debug("Auth path escalated from $authPath to ${outcome.path}") + authPath = outcome.path + savedStateHandle[KEY_AUTH_PATH] = authPath.name + } else { + logger.warn( + "Refusing invalid auth path transition $authPath → ${outcome.path}", + ) + } } when (val result = outcome.result) {