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(
|
SettingsScreen(
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
onNavigateToBackendSettings = { navController.navigate(NavRoutes.BACKEND_SETTINGS) },
|
onNavigateToBackendSettings = { navController.navigate(NavRoutes.BACKEND_SETTINGS) },
|
||||||
onNavigateToAppSettings = { navController.navigate(NavRoutes.APP_SETTINGS) },
|
onNavigateToAppSettings = { navController.navigate(NavRoutes.appSettings()) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
navigation(
|
navigation(
|
||||||
|
|
@ -86,6 +86,9 @@ class MainActivity : ComponentActivity() {
|
||||||
onSectionSelected = { sectionKey ->
|
onSectionSelected = { sectionKey ->
|
||||||
navController.navigate("backend_settings/$sectionKey")
|
navController.navigate("backend_settings/$sectionKey")
|
||||||
},
|
},
|
||||||
|
onNavigateToAppSettings = {
|
||||||
|
navController.navigate(NavRoutes.appSettings(localOnly = true))
|
||||||
|
},
|
||||||
viewModel = viewModel,
|
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(
|
AppSettingsScreen(
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,6 @@ val appModule = module {
|
||||||
// ViewModel
|
// ViewModel
|
||||||
viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get(), get()) }
|
viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get(), get()) }
|
||||||
viewModel { SettingsViewModel(get(), get(), get()) }
|
viewModel { SettingsViewModel(get(), get(), get()) }
|
||||||
viewModel { AppSettingsViewModel(get(), get()) }
|
viewModel { AppSettingsViewModel(get(), get(), get()) }
|
||||||
viewModel { SetupViewModel(get(), get()) }
|
viewModel { SetupViewModel(get(), get()) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ object NavRoutes {
|
||||||
const val BACKEND_SETTINGS = "backend_settings"
|
const val BACKEND_SETTINGS = "backend_settings"
|
||||||
const val BACKEND_SETTINGS_LIST = "backend_settings_list"
|
const val BACKEND_SETTINGS_LIST = "backend_settings_list"
|
||||||
const val BACKEND_SETTINGS_SECTION = "backend_settings/{sectionKey}"
|
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"
|
const val SETUP = "setup"
|
||||||
|
|
||||||
|
fun appSettings(localOnly: Boolean = false): String =
|
||||||
|
"app_settings?localOnly=$localOnly"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package io.clawdroid.settings
|
package io.clawdroid.settings
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import io.clawdroid.backend.api.GatewaySettings
|
import io.clawdroid.backend.api.GatewaySettings
|
||||||
|
|
@ -30,10 +31,13 @@ private fun portError(value: String): String? {
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppSettingsViewModel(
|
class AppSettingsViewModel(
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
private val settingsStore: GatewaySettingsStore,
|
private val settingsStore: GatewaySettingsStore,
|
||||||
private val configApiClient: ConfigApiClient,
|
private val configApiClient: ConfigApiClient,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val localOnly: Boolean = savedStateHandle["localOnly"] ?: false
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(AppSettingsUiState())
|
private val _uiState = MutableStateFlow(AppSettingsUiState())
|
||||||
val uiState: StateFlow<AppSettingsUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<AppSettingsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
|
@ -75,8 +79,9 @@ class AppSettingsViewModel(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (!localOnly) {
|
||||||
configApiClient.saveConfig(payload)
|
configApiClient.saveConfig(payload)
|
||||||
// Persist new values locally after remote success
|
}
|
||||||
settingsStore.update(GatewaySettings(httpPort = newPort, apiKey = newKey))
|
settingsStore.update(GatewaySettings(httpPort = newPort, apiKey = newKey))
|
||||||
_uiState.update { it.copy(saving = false) }
|
_uiState.update { it.copy(saving = false) }
|
||||||
onComplete()
|
onComplete()
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ data class SaveConfigResult(
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class AuthException(message: String) : IOException(message)
|
||||||
|
|
||||||
class ConfigApiClient(private val settingsStore: GatewaySettingsStore) : Closeable {
|
class ConfigApiClient(private val settingsStore: GatewaySettingsStore) : Closeable {
|
||||||
private val baseUrl: String
|
private val baseUrl: String
|
||||||
get() = settingsStore.settings.value.httpBaseUrl
|
get() = settingsStore.settings.value.httpBaseUrl
|
||||||
|
|
@ -84,7 +86,9 @@ class ConfigApiClient(private val settingsStore: GatewaySettingsStore) : Closeab
|
||||||
private suspend fun HttpResponse.ensureSuccess(): HttpResponse {
|
private suspend fun HttpResponse.ensureSuccess(): HttpResponse {
|
||||||
if (!status.isSuccess()) {
|
if (!status.isSuccess()) {
|
||||||
val error = runCatching { body<SaveConfigResult>().error }.getOrNull()
|
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
|
return this
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import io.clawdroid.core.ui.theme.TextSecondary
|
||||||
fun ConfigSectionListScreen(
|
fun ConfigSectionListScreen(
|
||||||
onNavigateBack: () -> Unit,
|
onNavigateBack: () -> Unit,
|
||||||
onSectionSelected: (sectionKey: String) -> Unit,
|
onSectionSelected: (sectionKey: String) -> Unit,
|
||||||
|
onNavigateToAppSettings: () -> Unit,
|
||||||
viewModel: ConfigViewModel,
|
viewModel: ConfigViewModel,
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
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 -> {
|
is ListState.Loaded -> {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ data class ConfigUiState(
|
||||||
sealed interface ListState {
|
sealed interface ListState {
|
||||||
data object Loading : ListState
|
data object Loading : ListState
|
||||||
data class Error(val message: String) : ListState
|
data class Error(val message: String) : ListState
|
||||||
|
data class AuthRequired(val message: String) : ListState
|
||||||
data class Loaded(val sections: List<SectionSummary>) : ListState
|
data class Loaded(val sections: List<SectionSummary>) : ListState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
@ -134,6 +135,7 @@ class ConfigViewModel(private val apiClient: ConfigApiClient) : ViewModel() {
|
||||||
_uiState.update { it.copy(listState = ListState.Loading) }
|
_uiState.update { it.copy(listState = ListState.Loading) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
|
coroutineScope {
|
||||||
val schemaDeferred = async { apiClient.getSchema() }
|
val schemaDeferred = async { apiClient.getSchema() }
|
||||||
val configDeferred = async { apiClient.getConfig() }
|
val configDeferred = async { apiClient.getConfig() }
|
||||||
val s = schemaDeferred.await()
|
val s = schemaDeferred.await()
|
||||||
|
|
@ -147,6 +149,15 @@ class ConfigViewModel(private val apiClient: ConfigApiClient) : ViewModel() {
|
||||||
pendingSectionKey = null
|
pendingSectionKey = null
|
||||||
loadSection(key)
|
loadSection(key)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} catch (e: AuthException) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
listState = ListState.AuthRequired(
|
||||||
|
e.message ?: "Authentication failed"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue