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:
@@ -19,22 +19,11 @@
|
||||
<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
|
||||
|
||||
@@ -2,7 +2,6 @@ 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
|
||||
@@ -14,8 +13,6 @@ 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
|
||||
@@ -54,43 +51,12 @@ class BonsaiAuthManager @Inject constructor(
|
||||
fun getUsername(): String = prefs.getString(KEY_USERNAME, "") ?: ""
|
||||
fun getAuthHeader(): String? = prefs.getString(KEY_ACCESS_TOKEN, null)?.let { "Bearer $it" }
|
||||
|
||||
fun buildAuthUrl(): String {
|
||||
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")
|
||||
|
||||
suspend fun login(username: String, password: String): LoginResult = withContext(Dispatchers.IO) {
|
||||
val body = FormBody.Builder()
|
||||
.add("grant_type", "authorization_code")
|
||||
.add("grant_type", "password")
|
||||
.add("client_id", CLIENT_ID)
|
||||
.add("code", code)
|
||||
.add("redirect_uri", REDIRECT_URI)
|
||||
.add("code_verifier", verifier)
|
||||
.add("username", username)
|
||||
.add("password", password)
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
@@ -105,13 +71,9 @@ class BonsaiAuthManager @Inject constructor(
|
||||
val detail = runCatching {
|
||||
JSONObject(raw).optString("error_description", raw)
|
||||
}.getOrDefault(raw)
|
||||
return@withContext LoginResult.Failure("HTTP ${response.code}: $detail")
|
||||
return@withContext LoginResult.Failure(detail)
|
||||
}
|
||||
saveTokens(JSONObject(raw))
|
||||
prefs.edit()
|
||||
.remove(KEY_PKCE_VERIFIER)
|
||||
.remove(KEY_OAUTH_STATE)
|
||||
.apply()
|
||||
LoginResult.Success
|
||||
}
|
||||
}.getOrElse { LoginResult.Failure(it.message ?: "Erreur réseau") }
|
||||
@@ -157,13 +119,12 @@ class BonsaiAuthManager @Inject constructor(
|
||||
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)
|
||||
.putString(KEY_USERNAME, extractUsername(accessToken))
|
||||
.apply()
|
||||
|
||||
_isAuthenticated.value = true
|
||||
@@ -177,27 +138,8 @@ class BonsaiAuthManager @Inject constructor(
|
||||
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"
|
||||
@@ -205,7 +147,5 @@ class BonsaiAuthManager @Inject constructor(
|
||||
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,6 +1,5 @@
|
||||
package com.planify.mobile.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -22,7 +21,6 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
handleIntent(intent)
|
||||
setContent {
|
||||
PlanifyTheme {
|
||||
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 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
|
||||
|
||||
@@ -18,7 +16,7 @@ sealed class AuthStatus {
|
||||
object Checking : AuthStatus()
|
||||
object NotAuthenticated : AuthStatus()
|
||||
data class Authenticated(val username: String) : AuthStatus()
|
||||
data class Error(val message: String) : AuthStatus()
|
||||
data class LoginError(val message: String) : AuthStatus()
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
@@ -40,8 +38,7 @@ class AuthViewModel @Inject constructor(
|
||||
_status.value = AuthStatus.NotAuthenticated
|
||||
return@launch
|
||||
}
|
||||
val refreshOk = authManager.refreshIfNeeded()
|
||||
if (refreshOk) {
|
||||
if (authManager.refreshIfNeeded()) {
|
||||
_status.value = AuthStatus.Authenticated(authManager.getUsername())
|
||||
syncManager.sync()
|
||||
} else {
|
||||
@@ -51,18 +48,16 @@ class AuthViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun buildAuthUrl(): String = authManager.buildAuthUrl()
|
||||
|
||||
fun handleOAuthCallback(code: String, state: String) {
|
||||
fun login(username: String, password: String) {
|
||||
_status.value = AuthStatus.Checking
|
||||
viewModelScope.launch {
|
||||
when (val result = authManager.exchangeCode(code, state)) {
|
||||
when (val result = authManager.login(username, password)) {
|
||||
is LoginResult.Success -> {
|
||||
_status.value = AuthStatus.Authenticated(authManager.getUsername())
|
||||
syncManager.sync()
|
||||
}
|
||||
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() {
|
||||
_status.value = AuthStatus.NotAuthenticated
|
||||
}
|
||||
|
||||
fun handleOAuthError(message: String) {
|
||||
_status.value = AuthStatus.Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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
|
||||
@@ -10,50 +8,81 @@ 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.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
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.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
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.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.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
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.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.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.planify.mobile.R
|
||||
|
||||
private val MintBackground = Color(0xFFF0FDF4)
|
||||
private val BonsaiGreen = Color(0xFF38A169)
|
||||
private val FieldBorder = Color(0xFFD1D5DB)
|
||||
private val LabelColor = Color(0xFF374151)
|
||||
private val TitleColor = Color(0xFF111827)
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(viewModel: AuthViewModel = hiltViewModel()) {
|
||||
fun LoginScreen(viewModel: AuthViewModel) {
|
||||
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(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MintBackground),
|
||||
.background(MintBackground)
|
||||
.imePadding(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
shadowElevation = 4.dp,
|
||||
shadowElevation = 6.dp,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp),
|
||||
.padding(horizontal = 28.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -65,13 +94,13 @@ fun LoginScreen(viewModel: AuthViewModel = hiltViewModel()) {
|
||||
modifier = Modifier.size(72.dp),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
Text(
|
||||
text = "Bonsai",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 22.sp,
|
||||
color = Color(0xFF111827),
|
||||
color = TitleColor,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
@@ -80,57 +109,126 @@ fun LoginScreen(viewModel: AuthViewModel = hiltViewModel()) {
|
||||
text = "Connexion",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 18.sp,
|
||||
color = Color(0xFF374151),
|
||||
color = LabelColor,
|
||||
)
|
||||
|
||||
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),
|
||||
)
|
||||
}
|
||||
// Username field
|
||||
Text(
|
||||
text = "Email ou nom d'utilisateur",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = LabelColor,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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 -> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
else -> {
|
||||
LoginButton {
|
||||
val url = viewModel.buildAuthUrl()
|
||||
CustomTabsIntent.Builder().setShowTitle(true).build()
|
||||
.launchUrl(context, Uri.parse(url))
|
||||
// Password field
|
||||
Text(
|
||||
text = "Mot de passe",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
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