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.WebSocketClient
|
||||||
import de.bollwerk.app.data.sync.WebSocketEvent
|
import de.bollwerk.app.data.sync.WebSocketEvent
|
||||||
import de.bollwerk.app.di.ApplicationScope
|
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.SettingsKey.StringKey
|
||||||
import de.bollwerk.app.domain.model.SyncError
|
import de.bollwerk.app.domain.model.SyncError
|
||||||
import de.bollwerk.app.domain.repository.MessageRepository
|
import de.bollwerk.app.domain.repository.MessageRepository
|
||||||
|
|
@ -54,6 +55,7 @@ internal class MessageRepositoryImpl @Inject constructor(
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
private val webSocketClient: WebSocketClient,
|
private val webSocketClient: WebSocketClient,
|
||||||
private val e2eeKeyManager: E2EEKeyManager,
|
private val e2eeKeyManager: E2EEKeyManager,
|
||||||
|
private val authEventBus: AuthEventBus,
|
||||||
@ApplicationScope private val scope: CoroutineScope
|
@ApplicationScope private val scope: CoroutineScope
|
||||||
) : MessageRepository {
|
) : MessageRepository {
|
||||||
|
|
||||||
|
|
@ -222,6 +224,7 @@ internal class MessageRepositoryImpl @Inject constructor(
|
||||||
settingsRepository.setString(StringKey.AuthRefreshToken, loginResponse.refreshToken)
|
settingsRepository.setString(StringKey.AuthRefreshToken, loginResponse.refreshToken)
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
|
authEventBus.notifySessionExpired()
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package de.bollwerk.app.data.sync
|
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.SettingsKey.StringKey
|
||||||
import de.bollwerk.app.domain.model.SyncError
|
import de.bollwerk.app.domain.model.SyncError
|
||||||
import de.bollwerk.app.domain.repository.SettingsRepository
|
import de.bollwerk.app.domain.repository.SettingsRepository
|
||||||
|
|
@ -29,7 +30,8 @@ import javax.inject.Inject
|
||||||
|
|
||||||
internal class SyncServiceImpl @Inject constructor(
|
internal class SyncServiceImpl @Inject constructor(
|
||||||
private val httpClient: HttpClient,
|
private val httpClient: HttpClient,
|
||||||
private val settingsRepository: SettingsRepository
|
private val settingsRepository: SettingsRepository,
|
||||||
|
private val authEventBus: AuthEventBus
|
||||||
) : SyncService {
|
) : SyncService {
|
||||||
|
|
||||||
override suspend fun downloadInventory(since: Long?): Result<InventoryDto> =
|
override suspend fun downloadInventory(since: Long?): Result<InventoryDto> =
|
||||||
|
|
@ -139,6 +141,7 @@ internal class SyncServiceImpl @Inject constructor(
|
||||||
if (newToken.isBlank()) return@withContext result
|
if (newToken.isBlank()) return@withContext result
|
||||||
block(serverUrl.trimEnd('/'), newToken)
|
block(serverUrl.trimEnd('/'), newToken)
|
||||||
} else {
|
} else {
|
||||||
|
authEventBus.notifySessionExpired()
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -189,6 +192,7 @@ internal class SyncServiceImpl @Inject constructor(
|
||||||
settingsRepository.setString(StringKey.AuthRefreshToken, loginResponse.refreshToken)
|
settingsRepository.setString(StringKey.AuthRefreshToken, loginResponse.refreshToken)
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
|
authEventBus.notifySessionExpired()
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} 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.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
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.InventoryPickerSheet
|
||||||
import de.bollwerk.app.ui.inventory.InventoryPickerViewModel
|
import de.bollwerk.app.ui.inventory.InventoryPickerViewModel
|
||||||
import de.bollwerk.app.ui.navigation.BollwerkNavGraph
|
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.navigation.TopLevelDestination
|
||||||
import de.bollwerk.app.ui.update.UpdateBanner
|
import de.bollwerk.app.ui.update.UpdateBanner
|
||||||
import de.bollwerk.app.ui.update.UpdateStatus
|
import de.bollwerk.app.ui.update.UpdateStatus
|
||||||
|
|
@ -53,6 +55,16 @@ internal fun MainScreen() {
|
||||||
currentDestination?.hasRoute(topLevel.route::class) == true
|
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 inventoryPickerViewModel: InventoryPickerViewModel = hiltViewModel()
|
||||||
val inventoryState by inventoryPickerViewModel.uiState.collectAsStateWithLifecycle()
|
val inventoryState by inventoryPickerViewModel.uiState.collectAsStateWithLifecycle()
|
||||||
var isInventoryPickerVisible by remember { mutableStateOf(false) }
|
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