Add registration tab to LoginScreen and implement OrganizerProfile screen

This commit is contained in:
2026-03-20 23:57:21 +01:00
parent 7f224f0d03
commit 499b93e100
6 changed files with 598 additions and 160 deletions
@@ -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)
@@ -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 {
@@ -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()
)
}
}
@@ -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
}