feat: authentification PKCE Keycloak au démarrage de l'application

Remplace le password grant par Authorization Code + PKCE via Custom Tab.
L'utilisateur est redirigé vers Keycloak à l'ouverture si non connecté,
le token est stocké dans EncryptedSharedPreferences et rafraîchi automatiquement.
Le deep link com.planify.mobile://auth/callback capture le code de retour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 09:24:58 +02:00
parent b08ceb5574
commit d099fc7da7
11 changed files with 441 additions and 141 deletions
+4 -1
View File
@@ -16,7 +16,7 @@ android {
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.0.7"
versionName = "0.0.8"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -83,6 +83,9 @@ dependencies {
// WorkManager
implementation(libs.work.runtime.ktx)
// Browser (Custom Tabs pour OAuth)
implementation(libs.androidx.browser)
// Serialization
implementation(libs.kotlinx.serialization.json)
+11
View File
@@ -19,11 +19,22 @@
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/Theme.PlanifyMobile">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Callback OAuth2 PKCE depuis le Custom Tab Keycloak -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="com.planify.mobile"
android:host="auth"
android:pathPrefix="/callback" />
</intent-filter>
</activity>
<receiver
@@ -1,15 +1,22 @@
package com.planify.mobile.data.bonsai
import android.content.Context
import android.content.SharedPreferences
import android.net.Uri
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Base64
import javax.inject.Inject
import javax.inject.Singleton
@@ -23,40 +30,71 @@ class BonsaiAuthManager @Inject constructor(
@ApplicationContext private val context: Context,
private val httpClient: OkHttpClient,
) {
private val prefs by lazy {
private val prefs: SharedPreferences
private val _isAuthenticated: MutableStateFlow<Boolean>
init {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
prefs = EncryptedSharedPreferences.create(
context,
"bonsai_credentials",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
_isAuthenticated = MutableStateFlow(prefs.getString(KEY_REFRESH_TOKEN, null) != null)
}
val isLoggedIn: Boolean get() = prefs.getString(KEY_TOKEN, null) != null
fun getApiBaseUrl(): String = prefs.getString(KEY_API_URL, DEFAULT_API_URL) ?: DEFAULT_API_URL
val isAuthenticated: StateFlow<Boolean> = _isAuthenticated
val isLoggedIn: Boolean get() = _isAuthenticated.value
fun getApiBaseUrl(): String = DEFAULT_API_URL
fun getUsername(): String = prefs.getString(KEY_USERNAME, "") ?: ""
fun getAuthHeader(): String? = prefs.getString(KEY_ACCESS_TOKEN, null)?.let { "Bearer $it" }
fun getAuthHeader(): String? = prefs.getString(KEY_TOKEN, null)?.let { "Bearer $it" }
fun buildAuthUrl(): String {
val verifier = generateCodeVerifier()
val challenge = generateCodeChallenge(verifier)
val state = generateState()
suspend fun login(apiUrl: String, username: String, password: String): LoginResult = withContext(Dispatchers.IO) {
val cleanUrl = apiUrl.trimEnd('/')
val tokenUrl = "$KEYCLOAK_BASE/realms/$REALM/protocol/openid-connect/token"
prefs.edit()
.putString(KEY_PKCE_VERIFIER, verifier)
.putString(KEY_OAUTH_STATE, state)
.apply()
return Uri.parse("$KEYCLOAK_BASE/realms/$REALM/protocol/openid-connect/auth")
.buildUpon()
.appendQueryParameter("client_id", CLIENT_ID)
.appendQueryParameter("response_type", "code")
.appendQueryParameter("redirect_uri", REDIRECT_URI)
.appendQueryParameter("scope", "openid profile email")
.appendQueryParameter("code_challenge", challenge)
.appendQueryParameter("code_challenge_method", "S256")
.appendQueryParameter("state", state)
.build()
.toString()
}
suspend fun exchangeCode(code: String, state: String): LoginResult = withContext(Dispatchers.IO) {
val storedState = prefs.getString(KEY_OAUTH_STATE, null)
if (storedState == null || storedState != state) {
return@withContext LoginResult.Failure("Erreur de sécurité : état invalide")
}
val verifier = prefs.getString(KEY_PKCE_VERIFIER, null)
?: return@withContext LoginResult.Failure("PKCE verifier manquant")
val body = FormBody.Builder()
.add("grant_type", "password")
.add("grant_type", "authorization_code")
.add("client_id", CLIENT_ID)
.add("username", username)
.add("password", password)
.add("code", code)
.add("redirect_uri", REDIRECT_URI)
.add("code_verifier", verifier)
.build()
val request = Request.Builder()
.url(tokenUrl)
.url("$KEYCLOAK_BASE/realms/$REALM/protocol/openid-connect/token")
.post(body)
.build()
@@ -64,35 +102,110 @@ class BonsaiAuthManager @Inject constructor(
httpClient.newCall(request).execute().use { response ->
val raw = response.body?.string() ?: ""
if (!response.isSuccessful) {
val detail = runCatching { JSONObject(raw).optString("error_description", raw) }.getOrDefault(raw)
val detail = runCatching {
JSONObject(raw).optString("error_description", raw)
}.getOrDefault(raw)
return@withContext LoginResult.Failure("HTTP ${response.code}: $detail")
}
val json = JSONObject(raw)
val token = json.getString("access_token")
saveTokens(JSONObject(raw))
prefs.edit()
.putString(KEY_TOKEN, token)
.putString(KEY_API_URL, cleanUrl)
.putString(KEY_USERNAME, username)
.remove(KEY_PKCE_VERIFIER)
.remove(KEY_OAUTH_STATE)
.apply()
LoginResult.Success
}
}.getOrElse { LoginResult.Failure(it.message ?: "Erreur réseau") }
}
suspend fun refreshIfNeeded(): Boolean = withContext(Dispatchers.IO) {
val refreshToken = prefs.getString(KEY_REFRESH_TOKEN, null) ?: return@withContext false
val expiresAt = prefs.getLong(KEY_EXPIRES_AT, 0L)
if (System.currentTimeMillis() < expiresAt - 60_000L) return@withContext true
val body = FormBody.Builder()
.add("grant_type", "refresh_token")
.add("client_id", CLIENT_ID)
.add("refresh_token", refreshToken)
.build()
val request = Request.Builder()
.url("$KEYCLOAK_BASE/realms/$REALM/protocol/openid-connect/token")
.post(body)
.build()
runCatching {
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext false
val raw = response.body?.string() ?: return@withContext false
saveTokens(JSONObject(raw))
true
}
}.getOrDefault(false)
}
fun logout() {
prefs.edit()
.remove(KEY_TOKEN)
.remove(KEY_ACCESS_TOKEN)
.remove(KEY_REFRESH_TOKEN)
.remove(KEY_EXPIRES_AT)
.remove(KEY_USERNAME)
.apply()
_isAuthenticated.value = false
}
private fun saveTokens(json: JSONObject) {
val accessToken = json.getString("access_token")
val refreshToken = json.optString("refresh_token", "")
val expiresIn = json.optLong("expires_in", 300L)
val username = extractUsername(accessToken)
prefs.edit()
.putString(KEY_ACCESS_TOKEN, accessToken)
.putString(KEY_REFRESH_TOKEN, refreshToken.ifBlank { null })
.putLong(KEY_EXPIRES_AT, System.currentTimeMillis() + expiresIn * 1000L)
.putString(KEY_USERNAME, username)
.apply()
_isAuthenticated.value = true
}
private fun extractUsername(jwt: String): String = runCatching {
val payload = jwt.split(".").getOrNull(1) ?: return@runCatching ""
val padded = payload + "=".repeat((4 - payload.length % 4) % 4)
val decoded = Base64.getUrlDecoder().decode(padded)
val json = JSONObject(String(decoded))
json.optString("preferred_username", json.optString("sub", ""))
}.getOrDefault("")
private fun generateCodeVerifier(): String {
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
private fun generateCodeChallenge(verifier: String): String {
val bytes = MessageDigest.getInstance("SHA-256")
.digest(verifier.toByteArray(Charsets.US_ASCII))
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
private fun generateState(): String {
val bytes = ByteArray(16)
SecureRandom().nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
companion object {
const val DEFAULT_API_URL = "https://bonsai.goutailler-olivier.com/api"
const val REDIRECT_URI = "com.planify.mobile://auth/callback"
private const val KEYCLOAK_BASE = "https://auth.goutailler-olivier.com"
private const val REALM = "bonsai"
private const val CLIENT_ID = "bonsai-webapp"
private const val KEY_TOKEN = "access_token"
private const val KEY_API_URL = "api_url"
private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_REFRESH_TOKEN = "refresh_token"
private const val KEY_EXPIRES_AT = "expires_at"
private const val KEY_USERNAME = "username"
private const val KEY_PKCE_VERIFIER = "pkce_verifier"
private const val KEY_OAUTH_STATE = "oauth_state"
}
}
@@ -1,21 +1,56 @@
package com.planify.mobile.ui
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import com.planify.mobile.ui.auth.AuthStatus
import com.planify.mobile.ui.auth.AuthViewModel
import com.planify.mobile.ui.auth.LoginScreen
import com.planify.mobile.ui.theme.PlanifyTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val authViewModel: AuthViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
handleIntent(intent)
setContent {
PlanifyTheme {
MainScreen()
val status by authViewModel.status.collectAsState()
when (status) {
is AuthStatus.Authenticated -> MainScreen(authViewModel)
else -> LoginScreen(authViewModel)
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
private fun handleIntent(intent: Intent) {
val data = intent.data ?: return
if (data.scheme == "com.planify.mobile" && data.host == "auth") {
val code = data.getQueryParameter("code")
val state = data.getQueryParameter("state")
if (code != null && state != null) {
authViewModel.handleOAuthCallback(code, state)
} else {
val error = data.getQueryParameter("error_description")
?: data.getQueryParameter("error")
?: "Connexion annulée"
authViewModel.handleOAuthError(error)
}
}
}
@@ -48,6 +48,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.planify.mobile.ui.auth.AuthViewModel
import com.planify.mobile.ui.navigation.DrawerViewModel
import com.planify.mobile.ui.navigation.PlanifyNavHost
import com.planify.mobile.ui.navigation.Route
@@ -56,7 +57,10 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(viewModel: DrawerViewModel = hiltViewModel()) {
fun MainScreen(
authViewModel: AuthViewModel,
viewModel: DrawerViewModel = hiltViewModel(),
) {
val navController = rememberNavController()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
@@ -204,6 +208,7 @@ fun MainScreen(viewModel: DrawerViewModel = hiltViewModel()) {
) { padding ->
PlanifyNavHost(
navController = navController,
authViewModel = authViewModel,
modifier = Modifier.padding(padding),
)
@@ -0,0 +1,83 @@
package com.planify.mobile.ui.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.data.bonsai.BonsaiAuthManager
import com.planify.mobile.data.bonsai.BonsaiSyncManager
import com.planify.mobile.data.bonsai.LoginResult
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed class AuthStatus {
object Checking : AuthStatus()
object NotAuthenticated : AuthStatus()
data class Authenticated(val username: String) : AuthStatus()
data class Error(val message: String) : AuthStatus()
}
@HiltViewModel
class AuthViewModel @Inject constructor(
private val authManager: BonsaiAuthManager,
private val syncManager: BonsaiSyncManager,
) : ViewModel() {
private val _status = MutableStateFlow<AuthStatus>(AuthStatus.Checking)
val status: StateFlow<AuthStatus> = _status.asStateFlow()
init {
checkAuthOnStartup()
}
private fun checkAuthOnStartup() {
viewModelScope.launch {
if (!authManager.isLoggedIn) {
_status.value = AuthStatus.NotAuthenticated
return@launch
}
val refreshOk = authManager.refreshIfNeeded()
if (refreshOk) {
_status.value = AuthStatus.Authenticated(authManager.getUsername())
syncManager.sync()
} else {
authManager.logout()
_status.value = AuthStatus.NotAuthenticated
}
}
}
fun buildAuthUrl(): String = authManager.buildAuthUrl()
fun handleOAuthCallback(code: String, state: String) {
_status.value = AuthStatus.Checking
viewModelScope.launch {
when (val result = authManager.exchangeCode(code, state)) {
is LoginResult.Success -> {
_status.value = AuthStatus.Authenticated(authManager.getUsername())
syncManager.sync()
}
is LoginResult.Failure -> {
_status.value = AuthStatus.Error(result.message)
}
}
}
}
fun logout() {
authManager.logout()
_status.value = AuthStatus.NotAuthenticated
}
fun clearError() {
_status.value = AuthStatus.NotAuthenticated
}
fun handleOAuthError(message: String) {
_status.value = AuthStatus.Error(message)
}
}
@@ -0,0 +1,136 @@
package com.planify.mobile.ui.auth
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.R
private val MintBackground = Color(0xFFF0FDF4)
private val BonsaiGreen = Color(0xFF38A169)
@Composable
fun LoginScreen(viewModel: AuthViewModel = hiltViewModel()) {
val status by viewModel.status.collectAsState()
val context = LocalContext.current
Box(
modifier = Modifier
.fillMaxSize()
.background(MintBackground),
contentAlignment = Alignment.Center,
) {
Surface(
shape = RoundedCornerShape(16.dp),
shadowElevation = 4.dp,
color = Color.White,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 32.dp),
) {
Image(
painter = painterResource(id = R.drawable.ic_bonsai_foreground),
contentDescription = "Bonsai",
modifier = Modifier.size(72.dp),
)
Spacer(Modifier.height(12.dp))
Text(
text = "Bonsai",
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
color = Color(0xFF111827),
)
Spacer(Modifier.height(4.dp))
Text(
text = "Connexion",
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
color = Color(0xFF374151),
)
Spacer(Modifier.height(24.dp))
when (status) {
is AuthStatus.Checking -> {
CircularProgressIndicator(color = BonsaiGreen)
Spacer(Modifier.height(8.dp))
Text(
text = "Vérification…",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF6B7280),
)
}
is AuthStatus.Error -> {
Text(
text = (status as AuthStatus.Error).message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 12.dp),
)
LoginButton {
val url = viewModel.buildAuthUrl()
CustomTabsIntent.Builder().setShowTitle(true).build()
.launchUrl(context, Uri.parse(url))
}
}
else -> {
LoginButton {
val url = viewModel.buildAuthUrl()
CustomTabsIntent.Builder().setShowTitle(true).build()
.launchUrl(context, Uri.parse(url))
}
}
}
}
}
}
}
@Composable
private fun LoginButton(onClick: () -> Unit) {
Button(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = BonsaiGreen),
shape = RoundedCornerShape(8.dp),
) {
Text("Se connecter", color = Color.White, fontWeight = FontWeight.Medium)
}
}
@@ -7,6 +7,7 @@ import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.planify.mobile.ui.auth.AuthViewModel
import com.planify.mobile.ui.filter.FilterScreen
import com.planify.mobile.ui.inbox.InboxScreen
import com.planify.mobile.ui.label.LabelScreen
@@ -19,6 +20,7 @@ import com.planify.mobile.ui.today.TodayScreen
@Composable
fun PlanifyNavHost(
navController: NavHostController,
authViewModel: AuthViewModel,
modifier: Modifier = Modifier,
) {
NavHost(
@@ -74,7 +76,7 @@ fun PlanifyNavHost(
}
composable(Route.Settings.path) {
SettingsScreen()
SettingsScreen(authViewModel = authViewModel)
}
}
}
@@ -12,7 +12,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -22,7 +21,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
@@ -31,19 +29,16 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.data.bonsai.BonsaiAuthManager
import com.planify.mobile.data.preferences.ThemeMode
import com.planify.mobile.ui.auth.AuthViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
authViewModel: AuthViewModel,
viewModel: SettingsViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsState()
@@ -54,7 +49,7 @@ fun SettingsScreen(
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
// ── Apparence ───────────────────────────────────────────────────────
// ── Apparence ───────────────────────────────────────────────────────
SectionTitle("Apparence")
ListItem(
headlineContent = { Text("Thème") },
@@ -74,7 +69,7 @@ fun SettingsScreen(
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
// ── Bonsai ──────────────────────────────────────────────────────────
// ── Bonsai ──────────────────────────────────────────────────────────
SectionTitle("Bonsai")
if (state.isLoggedIn) {
@@ -93,7 +88,7 @@ fun SettingsScreen(
},
)
state.syncSuccess && run {
if (state.syncSuccess) {
ListItem(
leadingContent = {
Icon(
@@ -102,9 +97,10 @@ fun SettingsScreen(
tint = MaterialTheme.colorScheme.primary,
)
},
headlineContent = { Text("Synchronisation réussie", color = MaterialTheme.colorScheme.primary) },
headlineContent = {
Text("Synchronisation réussie", color = MaterialTheme.colorScheme.primary)
},
)
true
}
state.syncError?.let { error ->
@@ -117,7 +113,7 @@ fun SettingsScreen(
}
OutlinedButton(
onClick = viewModel::logout,
onClick = authViewModel::logout,
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
modifier = Modifier
.fillMaxWidth()
@@ -126,11 +122,10 @@ fun SettingsScreen(
Text("Se déconnecter")
}
} else {
BonsaiLoginForm(
initialUrl = state.apiUrl,
isLoading = state.loginInProgress,
error = state.loginError,
onLogin = viewModel::login,
ListItem(
leadingContent = { Icon(Icons.Outlined.AccountCircle, null) },
headlineContent = { Text("Non connecté") },
supportingContent = { Text("Relancez l'application pour vous connecter") },
)
}
@@ -152,67 +147,6 @@ fun SettingsScreen(
}
}
@Composable
private fun BonsaiLoginForm(
initialUrl: String,
isLoading: Boolean,
error: String?,
onLogin: (url: String, username: String, password: String) -> Unit,
) {
var url by remember { mutableStateOf(initialUrl.ifBlank { BonsaiAuthManager.DEFAULT_API_URL }) }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text("URL du serveur") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Nom d'utilisateur") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Mot de passe") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
)
Spacer(Modifier.height(12.dp))
error?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 8.dp),
)
}
Button(
onClick = { onLogin(url.trim(), username.trim(), password) },
enabled = !isLoading && url.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.padding(end = 8.dp),
color = MaterialTheme.colorScheme.onPrimary,
)
}
Text("Se connecter à Bonsai")
}
}
}
@Composable
private fun SectionTitle(text: String) {
Text(
@@ -4,14 +4,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.data.bonsai.BonsaiAuthManager
import com.planify.mobile.data.bonsai.BonsaiSyncManager
import com.planify.mobile.data.bonsai.LoginResult
import com.planify.mobile.data.bonsai.SyncResult
import com.planify.mobile.data.preferences.AppPreferences
import com.planify.mobile.data.preferences.ThemeMode
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
@@ -23,9 +21,6 @@ data class SettingsUiState(
val notificationsEnabled: Boolean = true,
val isLoggedIn: Boolean = false,
val username: String = "",
val apiUrl: String = BonsaiAuthManager.DEFAULT_API_URL,
val loginInProgress: Boolean = false,
val loginError: String? = null,
val syncInProgress: Boolean = false,
val syncError: String? = null,
val syncSuccess: Boolean = false,
@@ -42,12 +37,21 @@ class SettingsViewModel @Inject constructor(
SettingsUiState(
isLoggedIn = authManager.isLoggedIn,
username = authManager.getUsername(),
apiUrl = authManager.getApiBaseUrl(),
)
)
val uiState = combine(prefs.themeMode, prefs.notificationsEnabled, _extra) { theme, notifs, extra ->
extra.copy(themeMode = theme, notificationsEnabled = notifs)
val uiState = combine(
prefs.themeMode,
prefs.notificationsEnabled,
authManager.isAuthenticated,
_extra,
) { theme, notifs, isAuth, extra ->
extra.copy(
themeMode = theme,
notificationsEnabled = notifs,
isLoggedIn = isAuth,
username = if (isAuth) authManager.getUsername() else "",
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
fun setTheme(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
@@ -56,34 +60,6 @@ class SettingsViewModel @Inject constructor(
prefs.setNotificationsEnabled(enabled)
}
fun login(apiUrl: String, username: String, password: String) {
_extra.update { it.copy(loginInProgress = true, loginError = null) }
viewModelScope.launch {
when (val result = authManager.login(apiUrl, username, password)) {
is LoginResult.Success -> {
_extra.update {
it.copy(
loginInProgress = false,
isLoggedIn = true,
username = authManager.getUsername(),
apiUrl = authManager.getApiBaseUrl(),
)
}
// Sync immediately after login
syncNow()
}
is LoginResult.Failure -> {
_extra.update { it.copy(loginInProgress = false, loginError = result.message) }
}
}
}
}
fun logout() {
authManager.logout()
_extra.update { it.copy(isLoggedIn = false, username = "", syncSuccess = false) }
}
fun syncNow() {
_extra.update { it.copy(syncInProgress = true, syncError = null, syncSuccess = false) }
viewModelScope.launch {
+2
View File
@@ -16,6 +16,7 @@ datastore = "1.1.1"
securityCrypto = "1.1.0-alpha06"
workManager = "2.10.0"
serialization = "1.7.3"
browser = "1.8.0"
junit = "4.13.2"
junitExt = "1.2.1"
espressoCore = "3.6.1"
@@ -50,6 +51,7 @@ datastore-preferences = { group = "androidx.datastore", name = "datastore-prefer
security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitExt" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }