feat: automatic forced logout on expired session
When both access and refresh token are invalid (401 on /auth/refresh), the app now automatically logs out and navigates to Settings (login form). No data loss - only auth tokens are cleared, local inventory data is intact. - AuthEventBus: singleton SharedFlow that signals session expiry - MainViewModel: observes bus, calls logout + disconnect, navigates to Settings - MainScreen: LaunchedEffect collects navigateToSettings event - MessageRepositoryImpl: emits session expired when refresh fails - SyncServiceImpl: emits session expired when refresh fails
This commit is contained in:
parent
a14c40d756
commit
575c0ad709
5 changed files with 78 additions and 1 deletions
|
|
@ -9,6 +9,7 @@ import de.bollwerk.app.data.sync.RefreshRequest
|
|||
import de.bollwerk.app.data.sync.WebSocketClient
|
||||
import de.bollwerk.app.data.sync.WebSocketEvent
|
||||
import de.bollwerk.app.di.ApplicationScope
|
||||
import de.bollwerk.app.domain.AuthEventBus
|
||||
import de.bollwerk.app.domain.model.SettingsKey.StringKey
|
||||
import de.bollwerk.app.domain.model.SyncError
|
||||
import de.bollwerk.app.domain.repository.MessageRepository
|
||||
|
|
@ -54,6 +55,7 @@ internal class MessageRepositoryImpl @Inject constructor(
|
|||
private val settingsRepository: SettingsRepository,
|
||||
private val webSocketClient: WebSocketClient,
|
||||
private val e2eeKeyManager: E2EEKeyManager,
|
||||
private val authEventBus: AuthEventBus,
|
||||
@ApplicationScope private val scope: CoroutineScope
|
||||
) : MessageRepository {
|
||||
|
||||
|
|
@ -222,6 +224,7 @@ internal class MessageRepositoryImpl @Inject constructor(
|
|||
settingsRepository.setString(StringKey.AuthRefreshToken, loginResponse.refreshToken)
|
||||
true
|
||||
} else {
|
||||
authEventBus.notifySessionExpired()
|
||||
false
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package de.bollwerk.app.data.sync
|
||||
|
||||
import de.bollwerk.app.domain.AuthEventBus
|
||||
import de.bollwerk.app.domain.model.SettingsKey.StringKey
|
||||
import de.bollwerk.app.domain.model.SyncError
|
||||
import de.bollwerk.app.domain.repository.SettingsRepository
|
||||
|
|
@ -29,7 +30,8 @@ import javax.inject.Inject
|
|||
|
||||
internal class SyncServiceImpl @Inject constructor(
|
||||
private val httpClient: HttpClient,
|
||||
private val settingsRepository: SettingsRepository
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val authEventBus: AuthEventBus
|
||||
) : SyncService {
|
||||
|
||||
override suspend fun downloadInventory(since: Long?): Result<InventoryDto> =
|
||||
|
|
@ -139,6 +141,7 @@ internal class SyncServiceImpl @Inject constructor(
|
|||
if (newToken.isBlank()) return@withContext result
|
||||
block(serverUrl.trimEnd('/'), newToken)
|
||||
} else {
|
||||
authEventBus.notifySessionExpired()
|
||||
result
|
||||
}
|
||||
} else {
|
||||
|
|
@ -189,6 +192,7 @@ internal class SyncServiceImpl @Inject constructor(
|
|||
settingsRepository.setString(StringKey.AuthRefreshToken, loginResponse.refreshToken)
|
||||
true
|
||||
} else {
|
||||
authEventBus.notifySessionExpired()
|
||||
false
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
|
|
|
|||
22
app/src/main/java/de/bollwerk/app/domain/AuthEventBus.kt
Normal file
22
app/src/main/java/de/bollwerk/app/domain/AuthEventBus.kt
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package de.bollwerk.app.domain
|
||||
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Singleton-Bus für auth-kritische Ereignisse.
|
||||
* Emittiert wenn sowohl Access- als auch Refresh-Token ungültig sind,
|
||||
* damit die App den User automatisch ausloggt ohne Datenverlust.
|
||||
*/
|
||||
@Singleton
|
||||
internal class AuthEventBus @Inject constructor() {
|
||||
private val _sessionExpired = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val sessionExpired: SharedFlow<Unit> = _sessionExpired.asSharedFlow()
|
||||
|
||||
fun notifySessionExpired() {
|
||||
_sessionExpired.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -37,6 +38,7 @@ import androidx.navigation.compose.rememberNavController
|
|||
import de.bollwerk.app.ui.inventory.InventoryPickerSheet
|
||||
import de.bollwerk.app.ui.inventory.InventoryPickerViewModel
|
||||
import de.bollwerk.app.ui.navigation.BollwerkNavGraph
|
||||
import de.bollwerk.app.ui.navigation.Screen
|
||||
import de.bollwerk.app.ui.navigation.TopLevelDestination
|
||||
import de.bollwerk.app.ui.update.UpdateBanner
|
||||
import de.bollwerk.app.ui.update.UpdateStatus
|
||||
|
|
@ -53,6 +55,16 @@ internal fun MainScreen() {
|
|||
currentDestination?.hasRoute(topLevel.route::class) == true
|
||||
}
|
||||
|
||||
val mainViewModel: MainViewModel = hiltViewModel()
|
||||
LaunchedEffect(Unit) {
|
||||
mainViewModel.navigateToSettings.collect {
|
||||
navController.navigate(Screen.Settings) {
|
||||
popUpTo(navController.graph.startDestinationId) { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val inventoryPickerViewModel: InventoryPickerViewModel = hiltViewModel()
|
||||
val inventoryState by inventoryPickerViewModel.uiState.collectAsStateWithLifecycle()
|
||||
var isInventoryPickerVisible by remember { mutableStateOf(false) }
|
||||
|
|
|
|||
36
app/src/main/java/de/bollwerk/app/ui/MainViewModel.kt
Normal file
36
app/src/main/java/de/bollwerk/app/ui/MainViewModel.kt
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package de.bollwerk.app.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.bollwerk.app.data.sync.WebSocketClient
|
||||
import de.bollwerk.app.domain.AuthEventBus
|
||||
import de.bollwerk.app.domain.repository.SyncService
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
internal class MainViewModel @Inject constructor(
|
||||
private val authEventBus: AuthEventBus,
|
||||
private val syncService: SyncService,
|
||||
private val webSocketClient: WebSocketClient
|
||||
) : ViewModel() {
|
||||
|
||||
private val _navigateToSettings = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val navigateToSettings: SharedFlow<Unit> = _navigateToSettings.asSharedFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
authEventBus.sessionExpired.collect {
|
||||
// Tokens löschen (kein Datenverlust – nur Auth-Daten)
|
||||
syncService.logout()
|
||||
webSocketClient.disconnect()
|
||||
// Navigation zu Settings (Login-Formular)
|
||||
_navigateToSettings.emit(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue