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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user