Add registration tab to LoginScreen and implement OrganizerProfile screen
This commit is contained in:
+4
-12
@@ -46,19 +46,10 @@ class AuthApiClient(
|
||||
formParameters = Parameters.build {
|
||||
append("grant_type", "password")
|
||||
append("client_id", clientId)
|
||||
|
||||
// WICHTIG: Senden Sie client_secret nur, wenn es sich NICHT um einen öffentlichen Client (wie 'web-app') handelt.
|
||||
// Keycloak lehnt Anfragen von öffentlichen Clients ab, die client_secret enthalten.
|
||||
// Wir prüfen, ob die Client-ID auf einen öffentlichen Client hindeutet oder ob ein Secret explizit angegeben wurde.
|
||||
// Aktuell gehen wir davon aus, dass 'web-app' öffentlich ist und daher kein Secret gesendet werden sollte.
|
||||
|
||||
// Logik: Wenn clientId 'web-app' ist, ignorieren wir das Geheimnis oder verlassen uns darauf, dass der Aufrufer null übergibt.
|
||||
// Da AppConstants möglicherweise noch das Geheimnis für 'postman-client' enthält, ist Vorsicht geboten.
|
||||
|
||||
if (!clientSecret.isNullOrBlank() && clientId != "web-app") {
|
||||
append("scope", "openid profile email")
|
||||
if (!clientSecret.isNullOrBlank()) {
|
||||
append("client_secret", clientSecret)
|
||||
}
|
||||
|
||||
append("username", username)
|
||||
append("password", password)
|
||||
}
|
||||
@@ -102,7 +93,8 @@ class AuthApiClient(
|
||||
formParameters = Parameters.build {
|
||||
append("grant_type", "refresh_token")
|
||||
append("client_id", clientId)
|
||||
if (!clientSecret.isNullOrBlank() && clientId != "web-app") {
|
||||
append("scope", "openid profile email")
|
||||
if (!clientSecret.isNullOrBlank()) {
|
||||
append("client_secret", clientSecret)
|
||||
}
|
||||
append("refresh_token", refreshToken)
|
||||
|
||||
+4
-2
@@ -45,7 +45,8 @@ enum class Permission {
|
||||
@Serializable
|
||||
data class JwtPayload(
|
||||
val sub: String? = null, // User ID
|
||||
val username: String? = null, // Username
|
||||
@kotlinx.serialization.SerialName("preferred_username")
|
||||
val username: String? = null, // Username (Keycloak: preferred_username)
|
||||
val exp: Long? = null, // Expiration timestamp
|
||||
val iat: Long? = null, // Issued at timestamp
|
||||
val iss: String? = null, // Issuer
|
||||
@@ -272,7 +273,8 @@ class AuthTokenManager {
|
||||
if (parts.size != 3) return null
|
||||
|
||||
// Decode the payload (second part)
|
||||
val payloadJson = Base64.decode(parts[1]).decodeToString()
|
||||
// JWT uses URL-safe Base64 without padding (RFC 4648 §5)
|
||||
val payloadJson = Base64.UrlSafe.decode(parts[1]).decodeToString()
|
||||
|
||||
// First, try to parse with a standard approach
|
||||
val basicPayload = try {
|
||||
|
||||
+240
-141
@@ -1,8 +1,9 @@
|
||||
package at.mocode.frontend.core.auth.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
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.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
@@ -25,16 +26,18 @@ import androidx.compose.ui.unit.dp
|
||||
fun LoginScreen(
|
||||
viewModel: LoginViewModel,
|
||||
onLoginSuccess: () -> Unit = {},
|
||||
onBack: () -> Unit = {} // New callback for back navigation
|
||||
onBack: () -> Unit = {}
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val focusManager = LocalFocusManager.current
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
// 0 = Anmelden, 1 = Registrieren
|
||||
var selectedTab by remember { mutableStateOf(0) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Anmelden") },
|
||||
title = { Text(if (selectedTab == 0) "Anmelden" else "Registrieren") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
|
||||
@@ -47,154 +50,250 @@ fun LoginScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
// Username field
|
||||
OutlinedTextField(
|
||||
value = uiState.username,
|
||||
onValueChange = viewModel::updateUsername,
|
||||
label = { Text("Benutzername") },
|
||||
enabled = !uiState.isLoading && !uiState.isOidcLoading,
|
||||
isError = uiState.usernameError != null,
|
||||
supportingText = uiState.usernameError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Next) }
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
// Password field
|
||||
OutlinedTextField(
|
||||
value = uiState.password,
|
||||
onValueChange = viewModel::updatePassword,
|
||||
label = { Text("Passwort") },
|
||||
enabled = !uiState.isLoading && !uiState.isOidcLoading,
|
||||
isError = uiState.passwordError != null,
|
||||
supportingText = uiState.passwordError?.let { { Text(it) } },
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
val image = if (passwordVisible)
|
||||
Icons.Filled.Visibility
|
||||
else
|
||||
Icons.Filled.VisibilityOff
|
||||
|
||||
val description = if (passwordVisible) "Passwort verbergen" else "Passwort anzeigen"
|
||||
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(imageVector = image, description)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
if (uiState.canLogin) {
|
||||
viewModel.login()
|
||||
}
|
||||
}
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
// Error message
|
||||
if (uiState.errorMessage != null) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = uiState.errorMessage!!,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Login button
|
||||
Button(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
viewModel.login()
|
||||
},
|
||||
enabled = uiState.canLogin && !uiState.isLoading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
} else {
|
||||
Text("Anmelden")
|
||||
}
|
||||
}
|
||||
|
||||
// Divider
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
HorizontalDivider(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = " oder ",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
PrimaryTabRow(selectedTabIndex = selectedTab) {
|
||||
Tab(
|
||||
selected = selectedTab == 0,
|
||||
onClick = { selectedTab = 0 },
|
||||
text = { Text("Anmelden") }
|
||||
)
|
||||
Tab(
|
||||
selected = selectedTab == 1,
|
||||
onClick = { selectedTab = 1 },
|
||||
text = { Text("Registrieren") }
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.weight(1f))
|
||||
}
|
||||
|
||||
// OIDC / Keycloak button
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.startOidcFlow() },
|
||||
enabled = !uiState.isLoading && !uiState.isOidcLoading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
) {
|
||||
if (uiState.isOidcLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Weiterleitung ...")
|
||||
} else {
|
||||
Text("Mit Keycloak anmelden")
|
||||
}
|
||||
if (selectedTab == 0) {
|
||||
LoginTabContent(
|
||||
uiState = uiState,
|
||||
focusManager = focusManager,
|
||||
onUsernameChange = viewModel::updateUsername,
|
||||
onPasswordChange = viewModel::updatePassword,
|
||||
onLogin = { focusManager.clearFocus(); viewModel.login() }
|
||||
)
|
||||
} else {
|
||||
RegisterTabContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle login success
|
||||
LaunchedEffect(uiState.isAuthenticated) {
|
||||
if (uiState.isAuthenticated) {
|
||||
onLoginSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoginTabContent(
|
||||
uiState: LoginUiState,
|
||||
focusManager: androidx.compose.ui.focus.FocusManager,
|
||||
onUsernameChange: (String) -> Unit,
|
||||
onPasswordChange: (String) -> Unit,
|
||||
onLogin: () -> Unit
|
||||
) {
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = uiState.username,
|
||||
onValueChange = onUsernameChange,
|
||||
label = { Text("Benutzername") },
|
||||
enabled = !uiState.isLoading && !uiState.isOidcLoading,
|
||||
isError = uiState.usernameError != null,
|
||||
supportingText = uiState.usernameError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = uiState.password,
|
||||
onValueChange = onPasswordChange,
|
||||
label = { Text("Passwort") },
|
||||
enabled = !uiState.isLoading && !uiState.isOidcLoading,
|
||||
isError = uiState.passwordError != null,
|
||||
supportingText = uiState.passwordError?.let { { Text(it) } },
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
|
||||
contentDescription = if (passwordVisible) "Passwort verbergen" else "Passwort anzeigen"
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { if (uiState.canLogin) onLogin() }),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
if (uiState.errorMessage != null) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = uiState.errorMessage,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onLogin,
|
||||
enabled = uiState.canLogin && !uiState.isLoading,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp)
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
} else {
|
||||
Text("Anmelden")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RegisterTabContent() {
|
||||
var displayName by remember { mutableStateOf("") }
|
||||
var email by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var passwordConfirm by remember { mutableStateOf("") }
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
var passwordConfirmVisible by remember { mutableStateOf(false) }
|
||||
var registerAttempted by remember { mutableStateOf(false) }
|
||||
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
val displayNameError = if (registerAttempted && displayName.isBlank()) "Name ist erforderlich" else null
|
||||
val emailError = when {
|
||||
registerAttempted && email.isBlank() -> "E-Mail ist erforderlich"
|
||||
registerAttempted && !email.contains("@") -> "Ungültige E-Mail-Adresse"
|
||||
else -> null
|
||||
}
|
||||
val passwordError = if (registerAttempted && password.length < 8) "Mindestens 8 Zeichen erforderlich" else null
|
||||
val passwordConfirmError = if (registerAttempted && password != passwordConfirm) "Passwörter stimmen nicht überein" else null
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Neues Konto erstellen",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 20.dp)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = displayName,
|
||||
onValueChange = { displayName = it },
|
||||
label = { Text("Vollständiger Name") },
|
||||
isError = displayNameError != null,
|
||||
supportingText = displayNameError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = { Text("E-Mail-Adresse") },
|
||||
isError = emailError != null,
|
||||
supportingText = emailError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Passwort") },
|
||||
isError = passwordError != null,
|
||||
supportingText = passwordError?.let { { Text(it) } },
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
|
||||
contentDescription = if (passwordVisible) "Passwort verbergen" else "Passwort anzeigen"
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = passwordConfirm,
|
||||
onValueChange = { passwordConfirm = it },
|
||||
label = { Text("Passwort bestätigen") },
|
||||
isError = passwordConfirmError != null,
|
||||
supportingText = passwordConfirmError?.let { { Text(it) } },
|
||||
visualTransformation = if (passwordConfirmVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordConfirmVisible = !passwordConfirmVisible }) {
|
||||
Icon(
|
||||
imageVector = if (passwordConfirmVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
|
||||
contentDescription = if (passwordConfirmVisible) "Passwort verbergen" else "Passwort anzeigen"
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); registerAttempted = true }),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
registerAttempted = true
|
||||
// TODO: Backend-Anbindung für Registrierung (POST /api/auth/register)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp)
|
||||
) {
|
||||
Text("Konto erstellen")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = "Mit der Registrierung stimmst du den Nutzungsbedingungen zu.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -11,6 +11,7 @@ sealed class AppScreen(val route: String) {
|
||||
|
||||
data object Ping : AppScreen("/ping")
|
||||
data object Profile : AppScreen("/profile")
|
||||
data object OrganizerProfile : AppScreen("/organizer/profile")
|
||||
data object AuthCallback : AppScreen("/auth/callback")
|
||||
|
||||
companion object {
|
||||
@@ -23,6 +24,7 @@ sealed class AppScreen(val route: String) {
|
||||
Routes.LOGIN, Routes.Auth.LOGIN -> Login()
|
||||
"/ping" -> Ping
|
||||
"/profile" -> Profile
|
||||
"/organizer/profile" -> OrganizerProfile
|
||||
"/auth/callback" -> AuthCallback
|
||||
else -> Landing // Default fallback
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user