From 40944a9a5535d9593e33cfdbae13276062414bf3 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 7 Oct 2026 10:07:23 -0400 Subject: [PATCH] feat: add Shaarli account config and 401 logout Add a "Compte Shaarli" settings section to edit the server URL and API secret with online re-validation, plus a logout action. Detect 401 responses from the Shaarli API via SessionManager, clear stored credentials and return the user to the login screen. --- app/src/main/java/com/shaarit/MainActivity.kt | 4 + .../shaarit/core/network/AuthInterceptor.kt | 15 +- .../shaarit/core/network/SessionManager.kt | 25 +++ .../com/shaarit/presentation/nav/NavGraph.kt | 31 ++- .../presentation/settings/SettingsScreen.kt | 184 ++++++++++++++++++ .../settings/SettingsViewModel.kt | 59 ++++++ 6 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/shaarit/core/network/SessionManager.kt diff --git a/app/src/main/java/com/shaarit/MainActivity.kt b/app/src/main/java/com/shaarit/MainActivity.kt index 586f634..f9f52a6 100644 --- a/app/src/main/java/com/shaarit/MainActivity.kt +++ b/app/src/main/java/com/shaarit/MainActivity.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat +import com.shaarit.core.network.SessionManager import com.shaarit.core.storage.BiometricAuthManager import com.shaarit.core.storage.SecurityPreferences import com.shaarit.presentation.auth.LockScreen @@ -42,6 +43,7 @@ class MainActivity : FragmentActivity() { @Inject lateinit var tokenManager: com.shaarit.core.storage.TokenManager @Inject lateinit var securityPreferences: SecurityPreferences @Inject lateinit var biometricAuthManager: BiometricAuthManager + @Inject lateinit var sessionManager: SessionManager @Inject lateinit var audioHandler: AudioHandler // Start as authenticated — lock only triggers after app goes to background @@ -100,6 +102,8 @@ class MainActivity : FragmentActivity() { Box(modifier = Modifier.fillMaxSize()) { AppNavGraph( startDestination = startDestination, + sessionManager = sessionManager, + tokenManager = tokenManager, shareUrl = shareData.url, shareTitle = shareData.title, shareDescription = shareData.description, diff --git a/app/src/main/java/com/shaarit/core/network/AuthInterceptor.kt b/app/src/main/java/com/shaarit/core/network/AuthInterceptor.kt index ef7286c..492e674 100644 --- a/app/src/main/java/com/shaarit/core/network/AuthInterceptor.kt +++ b/app/src/main/java/com/shaarit/core/network/AuthInterceptor.kt @@ -6,7 +6,10 @@ import javax.inject.Inject import okhttp3.Interceptor import okhttp3.Response -class AuthInterceptor @Inject constructor(private val tokenManager: TokenManager) : Interceptor { +class AuthInterceptor @Inject constructor( + private val tokenManager: TokenManager, + private val sessionManager: SessionManager +) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val original = chain.request() val builder = original.newBuilder() @@ -19,6 +22,14 @@ class AuthInterceptor @Inject constructor(private val tokenManager: TokenManager builder.header("Authorization", "Bearer $jwtToken") } - return chain.proceed(builder.build()) + val response = chain.proceed(builder.build()) + + // A 401 means our API secret no longer matches the one on the Shaarli server. + // Broadcast the event so the UI can clear credentials and return to the login screen. + if (response.code == 401) { + sessionManager.onUnauthorized() + } + + return response } } diff --git a/app/src/main/java/com/shaarit/core/network/SessionManager.kt b/app/src/main/java/com/shaarit/core/network/SessionManager.kt new file mode 100644 index 0000000..144d800 --- /dev/null +++ b/app/src/main/java/com/shaarit/core/network/SessionManager.kt @@ -0,0 +1,25 @@ +package com.shaarit.core.network + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Broadcasts session-expired events when the Shaarli API rejects our JWT with a 401. + * + * The Shaarli API secret can change server-side (e.g. "logout all devices" in the Shaarli web UI + * regenerates it), which invalidates every JWT generated locally. UI layers collect [unauthorized] + * to clear stored credentials and send the user back to the login screen. + */ +@Singleton +class SessionManager @Inject constructor() { + + private val _unauthorized = MutableSharedFlow(extraBufferCapacity = 1) + val unauthorized: SharedFlow = _unauthorized.asSharedFlow() + + fun onUnauthorized() { + _unauthorized.tryEmit(Unit) + } +} diff --git a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt index a45828a..05e408f 100644 --- a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt +++ b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt @@ -12,6 +12,8 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import androidx.navigation.navDeepLink +import com.shaarit.core.network.SessionManager +import com.shaarit.core.storage.TokenManager import java.net.URLEncoder sealed class Screen(val route: String) { @@ -60,6 +62,8 @@ sealed class Screen(val route: String) { @Composable fun AppNavGraph( startDestination: String = Screen.Login.route, + sessionManager: SessionManager? = null, + tokenManager: TokenManager? = null, shareUrl: String? = null, shareTitle: String? = null, shareDescription: String? = null, @@ -70,7 +74,24 @@ fun AppNavGraph( ) { val navController = rememberNavController() val context = LocalContext.current - + + // On a 401 from the Shaarli API, the stored API secret is stale (it changed server-side). + // Wipe the credentials and send the user back to the login screen. + if (sessionManager != null && tokenManager != null) { + androidx.compose.runtime.LaunchedEffect(sessionManager) { + sessionManager.unauthorized.collect { + tokenManager.clearToken() + tokenManager.clearApiSecret() + val currentRoute = navController.currentBackStackEntry?.destination?.route + if (currentRoute != Screen.Login.route) { + navController.navigate(Screen.Login.route) { + popUpTo(navController.graph.id) { inclusive = true } + launchSingleTop = true + } + } + } + } + } // If user is already logged in and has share data, navigate directly to Add screen val hasShareData = shareUrl != null || (isFileShare && shareTitle != null) androidx.compose.runtime.LaunchedEffect(hasShareData) { @@ -292,7 +313,13 @@ fun AppNavGraph( ) { com.shaarit.presentation.settings.SettingsScreen( onNavigateBack = { navController.popBackStack() }, - onNavigateToDashboard = { navController.navigate(Screen.Dashboard.route) } + onNavigateToDashboard = { navController.navigate(Screen.Dashboard.route) }, + onNavigateToLogin = { + navController.navigate(Screen.Login.route) { + popUpTo(navController.graph.id) { inclusive = true } + launchSingleTop = true + } + } ) } diff --git a/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt b/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt index 313c3c3..f58262e 100644 --- a/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt @@ -55,6 +55,7 @@ import java.util.* fun SettingsScreen( onNavigateBack: () -> Unit, onNavigateToDashboard: () -> Unit, + onNavigateToLogin: () -> Unit, viewModel: SettingsViewModel = hiltViewModel(), themePreferences: ThemePreferences = viewModel.themePreferences ) { @@ -125,6 +126,27 @@ fun SettingsScreen( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { + // Shaarli Account Section + item { + SettingsSection(title = "Compte Shaarli") + } + + item { + ShaarliAccountItem( + serverUrl = viewModel.shaarliUrl.collectAsState().value, + apiSecret = viewModel.shaarliSecret.collectAsState().value, + isConfigured = viewModel.isShaarliAccountConfigured(), + isSaving = viewModel.isSavingCredentials.collectAsState().value, + onUrlChange = { viewModel.updateShaarliUrl(it) }, + onSecretChange = { viewModel.updateShaarliSecret(it) }, + onSave = { viewModel.saveShaarliCredentials() }, + onLogout = { + viewModel.logoutShaarli() + onNavigateToLogin() + } + ) + } + // Theme Section item { SettingsSection(title = "Apparence") @@ -382,6 +404,168 @@ private fun SettingsSection(title: String) { ) } +@Composable +private fun ShaarliAccountItem( + serverUrl: String, + apiSecret: String, + isConfigured: Boolean, + isSaving: Boolean, + onUrlChange: (String) -> Unit, + onSecretChange: (String) -> Unit, + onSave: () -> Unit, + onLogout: () -> Unit +) { + var isExpanded by remember { mutableStateOf(false) } + var showSecret by remember { mutableStateOf(false) } + var showLogoutDialog by remember { mutableStateOf(false) } + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { isExpanded = !isExpanded } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Cloud, + contentDescription = null, + tint = if (isConfigured) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Serveur Shaarli", + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = if (isConfigured) "\u2713 Connect\u00e9" else "Non connect\u00e9", + style = MaterialTheme.typography.bodySmall, + color = if (isConfigured) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Icon( + imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (isExpanded) "R\u00e9duire" else "D\u00e9velopper", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (isExpanded) { + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Modifiez l'URL ou le secret API (Configuration \u2192 S\u00e9curit\u00e9 sur votre instance Shaarli) en cas d'erreur 401, puis sauvegardez.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = serverUrl, + onValueChange = onUrlChange, + modifier = Modifier.fillMaxWidth(), + label = { Text("URL du serveur") }, + placeholder = { Text("https://monserveur.com/shaarli") }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Next + ) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = apiSecret, + onValueChange = onSecretChange, + modifier = Modifier.fillMaxWidth(), + label = { Text("Secret API") }, + singleLine = true, + visualTransformation = if (showSecret) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + trailingIcon = { + IconButton(onClick = { showSecret = !showSecret }) { + Icon( + imageVector = if (showSecret) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (showSecret) "Masquer" else "Afficher" + ) + } + } + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + if (isConfigured) { + TextButton(onClick = { showLogoutDialog = true }) { + Icon( + Icons.Default.Logout, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Se d\u00e9connecter", color = MaterialTheme.colorScheme.error) + } + } else { + Spacer(modifier = Modifier.weight(1f)) + } + + if (isSaving) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) + } else { + TextButton(onClick = onSave) { + Icon( + Icons.Default.Save, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Sauvegarder") + } + } + } + } + } + } + + if (showLogoutDialog) { + AlertDialog( + onDismissRequest = { showLogoutDialog = false }, + title = { Text("Se d\u00e9connecter ?") }, + text = { Text("Le secret API sera effac\u00e9 de cet appareil. Vos favoris locaux sont conserv\u00e9s et vous devrez ressaisir le secret pour vous reconnecter.") }, + confirmButton = { + TextButton(onClick = { + showLogoutDialog = false + onLogout() + }) { + Text("Se d\u00e9connecter", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showLogoutDialog = false }) { + Text("Annuler") + } + } + ) + } +} + @Composable private fun SettingsItem( icon: androidx.compose.ui.graphics.vector.ImageVector, diff --git a/app/src/main/java/com/shaarit/presentation/settings/SettingsViewModel.kt b/app/src/main/java/com/shaarit/presentation/settings/SettingsViewModel.kt index ac448df..97965ca 100644 --- a/app/src/main/java/com/shaarit/presentation/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/shaarit/presentation/settings/SettingsViewModel.kt @@ -14,6 +14,8 @@ import com.shaarit.data.local.dao.LinkDao import com.shaarit.data.sync.SyncManager import com.shaarit.data.sync.SyncState import com.shaarit.data.worker.LinkHealthCheckWorker +import com.shaarit.domain.model.Credentials +import com.shaarit.domain.repository.AuthRepository import com.shaarit.domain.usecase.ClassifyBookmarksUseCase import com.shaarit.ui.theme.ThemePreferences import dagger.hilt.android.lifecycle.HiltViewModel @@ -34,6 +36,7 @@ class SettingsViewModel @Inject constructor( private val linkDao: LinkDao, private val classifyBookmarksUseCase: ClassifyBookmarksUseCase, private val tokenManager: TokenManager, + private val authRepository: AuthRepository, private val workManager: WorkManager, val themePreferences: ThemePreferences, val securityPreferences: SecurityPreferences, @@ -51,6 +54,15 @@ class SettingsViewModel @Inject constructor( private val _geminiApiKey = MutableStateFlow(tokenManager.getGeminiApiKey() ?: "") val geminiApiKey: StateFlow = _geminiApiKey.asStateFlow() + private val _shaarliUrl = MutableStateFlow(tokenManager.getBaseUrl() ?: "") + val shaarliUrl: StateFlow = _shaarliUrl.asStateFlow() + + private val _shaarliSecret = MutableStateFlow(tokenManager.getApiSecret() ?: "") + val shaarliSecret: StateFlow = _shaarliSecret.asStateFlow() + + private val _isSavingCredentials = MutableStateFlow(false) + val isSavingCredentials: StateFlow = _isSavingCredentials.asStateFlow() + // Health Check Statistics val totalBookmarks: StateFlow = linkDao.getTotalBookmarksCount() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) @@ -281,6 +293,53 @@ class SettingsViewModel @Inject constructor( fun isGeminiApiKeyConfigured(): Boolean { return !tokenManager.getGeminiApiKey().isNullOrBlank() } + + fun updateShaarliUrl(url: String) { + _shaarliUrl.value = url + } + + fun updateShaarliSecret(secret: String) { + _shaarliSecret.value = secret + } + + /** + * Re-validates the entered URL + API secret against the server (same flow as login). + * On failure the stored secret is cleared by the repository, but the base URL is kept + * so the login screen can still pre-fill it. + */ + fun saveShaarliCredentials() { + val url = _shaarliUrl.value.trim() + val secret = _shaarliSecret.value.trim() + if (url.isBlank() || secret.isBlank()) { + _uiState.value = _uiState.value.copy(message = "URL et secret API requis") + return + } + viewModelScope.launch { + _isSavingCredentials.value = true + authRepository.login(Credentials(url, secret), url) + .onSuccess { + syncManager.syncNow() + _uiState.value = _uiState.value.copy(message = "\u2713 Connexion r\u00e9ussie, identifiants Shaarli mis \u00e0 jour") + } + .onFailure { error -> + _uiState.value = _uiState.value.copy( + message = "\u00c9chec de la connexion : ${error.message ?: "erreur inconnue"}" + ) + } + _isSavingCredentials.value = false + } + } + + fun isShaarliAccountConfigured(): Boolean { + return authRepository.isLoggedIn() + } + + /** Clears the stored API secret (base URL is kept for pre-fill) so the user returns to login. */ + fun logoutShaarli() { + authRepository.logout() + _shaarliSecret.value = "" + _uiState.value = _uiState.value.copy(message = "D\u00e9connect\u00e9") + } } data class SettingsUiState(