feat: handle 403 auth errors in backend config and add local-only save mode
When the gateway returns 403, show a dedicated AuthRequired screen with a shortcut to Connection Settings (localOnly=true) so the user can fix the API key without hitting the unreachable server. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
84ccd83ada
commit
b790a63e7d
8 changed files with 91 additions and 18 deletions
|
|
@ -69,7 +69,7 @@ class MainActivity : ComponentActivity() {
|
|||
SettingsScreen(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToBackendSettings = { navController.navigate(NavRoutes.BACKEND_SETTINGS) },
|
||||
onNavigateToAppSettings = { navController.navigate(NavRoutes.APP_SETTINGS) },
|
||||
onNavigateToAppSettings = { navController.navigate(NavRoutes.appSettings()) },
|
||||
)
|
||||
}
|
||||
navigation(
|
||||
|
|
@ -86,6 +86,9 @@ class MainActivity : ComponentActivity() {
|
|||
onSectionSelected = { sectionKey ->
|
||||
navController.navigate("backend_settings/$sectionKey")
|
||||
},
|
||||
onNavigateToAppSettings = {
|
||||
navController.navigate(NavRoutes.appSettings(localOnly = true))
|
||||
},
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
|
|
@ -104,7 +107,15 @@ class MainActivity : ComponentActivity() {
|
|||
)
|
||||
}
|
||||
}
|
||||
composable(NavRoutes.APP_SETTINGS) {
|
||||
composable(
|
||||
NavRoutes.APP_SETTINGS,
|
||||
arguments = listOf(
|
||||
navArgument("localOnly") {
|
||||
type = NavType.BoolType
|
||||
defaultValue = false
|
||||
},
|
||||
),
|
||||
) {
|
||||
AppSettingsScreen(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -135,6 +135,6 @@ val appModule = module {
|
|||
// ViewModel
|
||||
viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get(), get()) }
|
||||
viewModel { SettingsViewModel(get(), get(), get()) }
|
||||
viewModel { AppSettingsViewModel(get(), get()) }
|
||||
viewModel { AppSettingsViewModel(get(), get(), get()) }
|
||||
viewModel { SetupViewModel(get(), get()) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ object NavRoutes {
|
|||
const val BACKEND_SETTINGS = "backend_settings"
|
||||
const val BACKEND_SETTINGS_LIST = "backend_settings_list"
|
||||
const val BACKEND_SETTINGS_SECTION = "backend_settings/{sectionKey}"
|
||||
const val APP_SETTINGS = "app_settings"
|
||||
const val APP_SETTINGS = "app_settings?localOnly={localOnly}"
|
||||
const val SETUP = "setup"
|
||||
|
||||
fun appSettings(localOnly: Boolean = false): String =
|
||||
"app_settings?localOnly=$localOnly"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package io.clawdroid.settings
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.clawdroid.backend.api.GatewaySettings
|
||||
|
|
@ -30,10 +31,13 @@ private fun portError(value: String): String? {
|
|||
}
|
||||
|
||||
class AppSettingsViewModel(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val settingsStore: GatewaySettingsStore,
|
||||
private val configApiClient: ConfigApiClient,
|
||||
) : ViewModel() {
|
||||
|
||||
private val localOnly: Boolean = savedStateHandle["localOnly"] ?: false
|
||||
|
||||
private val _uiState = MutableStateFlow(AppSettingsUiState())
|
||||
val uiState: StateFlow<AppSettingsUiState> = _uiState.asStateFlow()
|
||||
|
||||
|
|
@ -75,8 +79,9 @@ class AppSettingsViewModel(
|
|||
}
|
||||
|
||||
try {
|
||||
configApiClient.saveConfig(payload)
|
||||
// Persist new values locally after remote success
|
||||
if (!localOnly) {
|
||||
configApiClient.saveConfig(payload)
|
||||
}
|
||||
settingsStore.update(GatewaySettings(httpPort = newPort, apiKey = newKey))
|
||||
_uiState.update { it.copy(saving = false) }
|
||||
onComplete()
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ data class SaveConfigResult(
|
|||
val error: String? = null,
|
||||
)
|
||||
|
||||
class AuthException(message: String) : IOException(message)
|
||||
|
||||
class ConfigApiClient(private val settingsStore: GatewaySettingsStore) : Closeable {
|
||||
private val baseUrl: String
|
||||
get() = settingsStore.settings.value.httpBaseUrl
|
||||
|
|
@ -84,7 +86,9 @@ class ConfigApiClient(private val settingsStore: GatewaySettingsStore) : Closeab
|
|||
private suspend fun HttpResponse.ensureSuccess(): HttpResponse {
|
||||
if (!status.isSuccess()) {
|
||||
val error = runCatching { body<SaveConfigResult>().error }.getOrNull()
|
||||
throw IOException("HTTP ${status.value}: ${error ?: "request failed"}")
|
||||
val message = "HTTP ${status.value}: ${error ?: "request failed"}"
|
||||
if (status.value == 403) throw AuthException(message)
|
||||
throw IOException(message)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import io.clawdroid.core.ui.theme.TextSecondary
|
|||
fun ConfigSectionListScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onSectionSelected: (sectionKey: String) -> Unit,
|
||||
onNavigateToAppSettings: () -> Unit,
|
||||
viewModel: ConfigViewModel,
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
|
@ -112,6 +113,43 @@ fun ConfigSectionListScreen(
|
|||
}
|
||||
}
|
||||
|
||||
is ListState.AuthRequired -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
listState.message,
|
||||
color = TextSecondary,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = onNavigateToAppSettings,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = NeonCyan,
|
||||
contentColor = DeepBlack,
|
||||
),
|
||||
) {
|
||||
Text("Connection Settings")
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = viewModel::retry,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = NeonCyan,
|
||||
),
|
||||
) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is ListState.Loaded -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ data class ConfigUiState(
|
|||
sealed interface ListState {
|
||||
data object Loading : ListState
|
||||
data class Error(val message: String) : ListState
|
||||
data class AuthRequired(val message: String) : ListState
|
||||
data class Loaded(val sections: List<SectionSummary>) : ListState
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
|
@ -134,18 +135,28 @@ class ConfigViewModel(private val apiClient: ConfigApiClient) : ViewModel() {
|
|||
_uiState.update { it.copy(listState = ListState.Loading) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val schemaDeferred = async { apiClient.getSchema() }
|
||||
val configDeferred = async { apiClient.getConfig() }
|
||||
val s = schemaDeferred.await()
|
||||
val c = configDeferred.await()
|
||||
schema = s
|
||||
configValues = c
|
||||
_uiState.update {
|
||||
it.copy(listState = ListState.Loaded(s.toSummaries()))
|
||||
coroutineScope {
|
||||
val schemaDeferred = async { apiClient.getSchema() }
|
||||
val configDeferred = async { apiClient.getConfig() }
|
||||
val s = schemaDeferred.await()
|
||||
val c = configDeferred.await()
|
||||
schema = s
|
||||
configValues = c
|
||||
_uiState.update {
|
||||
it.copy(listState = ListState.Loaded(s.toSummaries()))
|
||||
}
|
||||
pendingSectionKey?.let { key ->
|
||||
pendingSectionKey = null
|
||||
loadSection(key)
|
||||
}
|
||||
}
|
||||
pendingSectionKey?.let { key ->
|
||||
pendingSectionKey = null
|
||||
loadSection(key)
|
||||
} catch (e: AuthException) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
listState = ListState.AuthRequired(
|
||||
e.message ?: "Authentication failed"
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue