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.
This commit is contained in:
2026-10-07 10:07:23 -04:00
parent 4d8d6f5172
commit 40944a9a55
6 changed files with 314 additions and 4 deletions
@@ -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,
@@ -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
}
}
@@ -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<Unit>(extraBufferCapacity = 1)
val unauthorized: SharedFlow<Unit> = _unauthorized.asSharedFlow()
fun onUnauthorized() {
_unauthorized.tryEmit(Unit)
}
}
@@ -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
}
}
)
}
@@ -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,
@@ -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<String> = _geminiApiKey.asStateFlow()
private val _shaarliUrl = MutableStateFlow(tokenManager.getBaseUrl() ?: "")
val shaarliUrl: StateFlow<String> = _shaarliUrl.asStateFlow()
private val _shaarliSecret = MutableStateFlow(tokenManager.getApiSecret() ?: "")
val shaarliSecret: StateFlow<String> = _shaarliSecret.asStateFlow()
private val _isSavingCredentials = MutableStateFlow(false)
val isSavingCredentials: StateFlow<Boolean> = _isSavingCredentials.asStateFlow()
// Health Check Statistics
val totalBookmarks: StateFlow<Int> = 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(