feat: connexion ROPC — formulaire natif sans redirection Keycloak
Remplace le flux PKCE/Custom Tab par un formulaire username/password natif qui appelle directement le token endpoint Keycloak (grant_type=password). Le token et le refresh token sont stockés dans EncryptedSharedPreferences. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@ android {
|
|||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.0.8"
|
versionName = "0.0.9"
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,22 +19,11 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".ui.MainActivity"
|
android:name=".ui.MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTask"
|
|
||||||
android:theme="@style/Theme.PlanifyMobile">
|
android:theme="@style/Theme.PlanifyMobile">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.planify.mobile.data.bonsai
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.net.Uri
|
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKey
|
import androidx.security.crypto.MasterKey
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
@@ -14,8 +13,6 @@ import okhttp3.FormBody
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.security.SecureRandom
|
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -54,43 +51,12 @@ class BonsaiAuthManager @Inject constructor(
|
|||||||
fun getUsername(): String = prefs.getString(KEY_USERNAME, "") ?: ""
|
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_ACCESS_TOKEN, null)?.let { "Bearer $it" }
|
||||||
|
|
||||||
fun buildAuthUrl(): String {
|
suspend fun login(username: String, password: String): LoginResult = withContext(Dispatchers.IO) {
|
||||||
val verifier = generateCodeVerifier()
|
|
||||||
val challenge = generateCodeChallenge(verifier)
|
|
||||||
val state = generateState()
|
|
||||||
|
|
||||||
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()
|
val body = FormBody.Builder()
|
||||||
.add("grant_type", "authorization_code")
|
.add("grant_type", "password")
|
||||||
.add("client_id", CLIENT_ID)
|
.add("client_id", CLIENT_ID)
|
||||||
.add("code", code)
|
.add("username", username)
|
||||||
.add("redirect_uri", REDIRECT_URI)
|
.add("password", password)
|
||||||
.add("code_verifier", verifier)
|
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
@@ -105,13 +71,9 @@ class BonsaiAuthManager @Inject constructor(
|
|||||||
val detail = runCatching {
|
val detail = runCatching {
|
||||||
JSONObject(raw).optString("error_description", raw)
|
JSONObject(raw).optString("error_description", raw)
|
||||||
}.getOrDefault(raw)
|
}.getOrDefault(raw)
|
||||||
return@withContext LoginResult.Failure("HTTP ${response.code}: $detail")
|
return@withContext LoginResult.Failure(detail)
|
||||||
}
|
}
|
||||||
saveTokens(JSONObject(raw))
|
saveTokens(JSONObject(raw))
|
||||||
prefs.edit()
|
|
||||||
.remove(KEY_PKCE_VERIFIER)
|
|
||||||
.remove(KEY_OAUTH_STATE)
|
|
||||||
.apply()
|
|
||||||
LoginResult.Success
|
LoginResult.Success
|
||||||
}
|
}
|
||||||
}.getOrElse { LoginResult.Failure(it.message ?: "Erreur réseau") }
|
}.getOrElse { LoginResult.Failure(it.message ?: "Erreur réseau") }
|
||||||
@@ -157,13 +119,12 @@ class BonsaiAuthManager @Inject constructor(
|
|||||||
val accessToken = json.getString("access_token")
|
val accessToken = json.getString("access_token")
|
||||||
val refreshToken = json.optString("refresh_token", "")
|
val refreshToken = json.optString("refresh_token", "")
|
||||||
val expiresIn = json.optLong("expires_in", 300L)
|
val expiresIn = json.optLong("expires_in", 300L)
|
||||||
val username = extractUsername(accessToken)
|
|
||||||
|
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString(KEY_ACCESS_TOKEN, accessToken)
|
.putString(KEY_ACCESS_TOKEN, accessToken)
|
||||||
.putString(KEY_REFRESH_TOKEN, refreshToken.ifBlank { null })
|
.putString(KEY_REFRESH_TOKEN, refreshToken.ifBlank { null })
|
||||||
.putLong(KEY_EXPIRES_AT, System.currentTimeMillis() + expiresIn * 1000L)
|
.putLong(KEY_EXPIRES_AT, System.currentTimeMillis() + expiresIn * 1000L)
|
||||||
.putString(KEY_USERNAME, username)
|
.putString(KEY_USERNAME, extractUsername(accessToken))
|
||||||
.apply()
|
.apply()
|
||||||
|
|
||||||
_isAuthenticated.value = true
|
_isAuthenticated.value = true
|
||||||
@@ -177,27 +138,8 @@ class BonsaiAuthManager @Inject constructor(
|
|||||||
json.optString("preferred_username", json.optString("sub", ""))
|
json.optString("preferred_username", json.optString("sub", ""))
|
||||||
}.getOrDefault("")
|
}.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 {
|
companion object {
|
||||||
const val DEFAULT_API_URL = "https://bonsai.goutailler-olivier.com/api"
|
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 KEYCLOAK_BASE = "https://auth.goutailler-olivier.com"
|
||||||
private const val REALM = "bonsai"
|
private const val REALM = "bonsai"
|
||||||
private const val CLIENT_ID = "bonsai-webapp"
|
private const val CLIENT_ID = "bonsai-webapp"
|
||||||
@@ -205,7 +147,5 @@ class BonsaiAuthManager @Inject constructor(
|
|||||||
private const val KEY_REFRESH_TOKEN = "refresh_token"
|
private const val KEY_REFRESH_TOKEN = "refresh_token"
|
||||||
private const val KEY_EXPIRES_AT = "expires_at"
|
private const val KEY_EXPIRES_AT = "expires_at"
|
||||||
private const val KEY_USERNAME = "username"
|
private const val KEY_USERNAME = "username"
|
||||||
private const val KEY_PKCE_VERIFIER = "pkce_verifier"
|
|
||||||
private const val KEY_OAUTH_STATE = "oauth_state"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.planify.mobile.ui
|
package com.planify.mobile.ui
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
@@ -22,7 +21,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
handleIntent(intent)
|
|
||||||
setContent {
|
setContent {
|
||||||
PlanifyTheme {
|
PlanifyTheme {
|
||||||
val status by authViewModel.status.collectAsState()
|
val status by authViewModel.status.collectAsState()
|
||||||
@@ -33,25 +31,4 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ import com.planify.mobile.data.bonsai.BonsaiSyncManager
|
|||||||
import com.planify.mobile.data.bonsai.LoginResult
|
import com.planify.mobile.data.bonsai.LoginResult
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -18,7 +16,7 @@ sealed class AuthStatus {
|
|||||||
object Checking : AuthStatus()
|
object Checking : AuthStatus()
|
||||||
object NotAuthenticated : AuthStatus()
|
object NotAuthenticated : AuthStatus()
|
||||||
data class Authenticated(val username: String) : AuthStatus()
|
data class Authenticated(val username: String) : AuthStatus()
|
||||||
data class Error(val message: String) : AuthStatus()
|
data class LoginError(val message: String) : AuthStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -40,8 +38,7 @@ class AuthViewModel @Inject constructor(
|
|||||||
_status.value = AuthStatus.NotAuthenticated
|
_status.value = AuthStatus.NotAuthenticated
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val refreshOk = authManager.refreshIfNeeded()
|
if (authManager.refreshIfNeeded()) {
|
||||||
if (refreshOk) {
|
|
||||||
_status.value = AuthStatus.Authenticated(authManager.getUsername())
|
_status.value = AuthStatus.Authenticated(authManager.getUsername())
|
||||||
syncManager.sync()
|
syncManager.sync()
|
||||||
} else {
|
} else {
|
||||||
@@ -51,18 +48,16 @@ class AuthViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun buildAuthUrl(): String = authManager.buildAuthUrl()
|
fun login(username: String, password: String) {
|
||||||
|
|
||||||
fun handleOAuthCallback(code: String, state: String) {
|
|
||||||
_status.value = AuthStatus.Checking
|
_status.value = AuthStatus.Checking
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
when (val result = authManager.exchangeCode(code, state)) {
|
when (val result = authManager.login(username, password)) {
|
||||||
is LoginResult.Success -> {
|
is LoginResult.Success -> {
|
||||||
_status.value = AuthStatus.Authenticated(authManager.getUsername())
|
_status.value = AuthStatus.Authenticated(authManager.getUsername())
|
||||||
syncManager.sync()
|
syncManager.sync()
|
||||||
}
|
}
|
||||||
is LoginResult.Failure -> {
|
is LoginResult.Failure -> {
|
||||||
_status.value = AuthStatus.Error(result.message)
|
_status.value = AuthStatus.LoginError(result.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,8 +71,4 @@ class AuthViewModel @Inject constructor(
|
|||||||
fun clearError() {
|
fun clearError() {
|
||||||
_status.value = AuthStatus.NotAuthenticated
|
_status.value = AuthStatus.NotAuthenticated
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleOAuthError(message: String) {
|
|
||||||
_status.value = AuthStatus.Error(message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package com.planify.mobile.ui.auth
|
package com.planify.mobile.ui.auth
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.browser.customtabs.CustomTabsIntent
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -10,50 +8,81 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Visibility
|
||||||
|
import androidx.compose.material.icons.outlined.VisibilityOff
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import com.planify.mobile.R
|
import com.planify.mobile.R
|
||||||
|
|
||||||
private val MintBackground = Color(0xFFF0FDF4)
|
private val MintBackground = Color(0xFFF0FDF4)
|
||||||
private val BonsaiGreen = Color(0xFF38A169)
|
private val BonsaiGreen = Color(0xFF38A169)
|
||||||
|
private val FieldBorder = Color(0xFFD1D5DB)
|
||||||
|
private val LabelColor = Color(0xFF374151)
|
||||||
|
private val TitleColor = Color(0xFF111827)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginScreen(viewModel: AuthViewModel = hiltViewModel()) {
|
fun LoginScreen(viewModel: AuthViewModel) {
|
||||||
val status by viewModel.status.collectAsState()
|
val status by viewModel.status.collectAsState()
|
||||||
val context = LocalContext.current
|
val isLoading = status is AuthStatus.Checking
|
||||||
|
val errorMessage = (status as? AuthStatus.LoginError)?.message
|
||||||
|
|
||||||
|
var username by rememberSaveable { mutableStateOf("") }
|
||||||
|
var password by rememberSaveable { mutableStateOf("") }
|
||||||
|
var passwordVisible by remember { mutableStateOf(false) }
|
||||||
|
val passwordFocus = remember { FocusRequester() }
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MintBackground),
|
.background(MintBackground)
|
||||||
|
.imePadding(),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
shadowElevation = 4.dp,
|
shadowElevation = 6.dp,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 32.dp),
|
.padding(horizontal = 28.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
@@ -65,13 +94,13 @@ fun LoginScreen(viewModel: AuthViewModel = hiltViewModel()) {
|
|||||||
modifier = Modifier.size(72.dp),
|
modifier = Modifier.size(72.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Bonsai",
|
text = "Bonsai",
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontSize = 22.sp,
|
fontSize = 22.sp,
|
||||||
color = Color(0xFF111827),
|
color = TitleColor,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
@@ -80,57 +109,126 @@ fun LoginScreen(viewModel: AuthViewModel = hiltViewModel()) {
|
|||||||
text = "Connexion",
|
text = "Connexion",
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
color = Color(0xFF374151),
|
color = LabelColor,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
when (status) {
|
// Username field
|
||||||
is AuthStatus.Checking -> {
|
Text(
|
||||||
CircularProgressIndicator(color = BonsaiGreen)
|
text = "Email ou nom d'utilisateur",
|
||||||
Spacer(Modifier.height(8.dp))
|
style = MaterialTheme.typography.bodySmall,
|
||||||
Text(
|
color = LabelColor,
|
||||||
text = "Vérification…",
|
modifier = Modifier
|
||||||
style = MaterialTheme.typography.bodySmall,
|
.fillMaxWidth()
|
||||||
color = Color(0xFF6B7280),
|
.padding(bottom = 4.dp),
|
||||||
)
|
)
|
||||||
}
|
OutlinedTextField(
|
||||||
|
value = username,
|
||||||
|
onValueChange = { username = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !isLoading,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
imeAction = ImeAction.Next,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { passwordFocus.requestFocus() }
|
||||||
|
),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
unfocusedBorderColor = FieldBorder,
|
||||||
|
focusedBorderColor = BonsaiGreen,
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(6.dp),
|
||||||
|
)
|
||||||
|
|
||||||
is AuthStatus.Error -> {
|
Spacer(Modifier.height(12.dp))
|
||||||
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 -> {
|
// Password field
|
||||||
LoginButton {
|
Text(
|
||||||
val url = viewModel.buildAuthUrl()
|
text = "Mot de passe",
|
||||||
CustomTabsIntent.Builder().setShowTitle(true).build()
|
style = MaterialTheme.typography.bodySmall,
|
||||||
.launchUrl(context, Uri.parse(url))
|
color = LabelColor,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 4.dp),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(passwordFocus),
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !isLoading,
|
||||||
|
visualTransformation = if (passwordVisible) VisualTransformation.None
|
||||||
|
else PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
if (username.isNotBlank() && password.isNotBlank()) {
|
||||||
|
viewModel.login(username.trim(), password)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (passwordVisible) Icons.Outlined.Visibility
|
||||||
|
else Icons.Outlined.VisibilityOff,
|
||||||
|
contentDescription = if (passwordVisible) "Masquer" else "Afficher",
|
||||||
|
tint = Color(0xFF6B7280),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
unfocusedBorderColor = FieldBorder,
|
||||||
|
focusedBorderColor = BonsaiGreen,
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(6.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
errorMessage?.let {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.login(username.trim(), password) },
|
||||||
|
enabled = !isLoading && username.isNotBlank() && password.isNotBlank(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = BonsaiGreen),
|
||||||
|
shape = RoundedCornerShape(6.dp),
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
color = Color.White,
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "Se connecter",
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user