diff --git a/config/docker/keycloak/meldestelle-realm.json b/config/docker/keycloak/meldestelle-realm.json index f601d45a..b3f301b8 100644 --- a/config/docker/keycloak/meldestelle-realm.json +++ b/config/docker/keycloak/meldestelle-realm.json @@ -326,7 +326,11 @@ "USER", "ORGANIZER" ], - "clientRoles": {} + "clientRoles": { + "api-gateway": [ + "ORGANIZER" + ] + } } ], "groups": [], diff --git a/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/data/AuthApiClient.kt b/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/data/AuthApiClient.kt index 59cfacb0..26b12e6d 100644 --- a/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/data/AuthApiClient.kt +++ b/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/data/AuthApiClient.kt @@ -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) diff --git a/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/data/AuthTokenManager.kt b/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/data/AuthTokenManager.kt index e16c6724..1b875a5f 100644 --- a/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/data/AuthTokenManager.kt +++ b/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/data/AuthTokenManager.kt @@ -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 { diff --git a/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/presentation/LoginScreen.kt b/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/presentation/LoginScreen.kt index b0fdc745..e3f6b2cb 100644 --- a/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/presentation/LoginScreen.kt +++ b/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/presentation/LoginScreen.kt @@ -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() + ) + } +} diff --git a/frontend/core/navigation/src/commonMain/kotlin/at/mocode/frontend/core/navigation/AppScreen.kt b/frontend/core/navigation/src/commonMain/kotlin/at/mocode/frontend/core/navigation/AppScreen.kt index bfbc7dda..0031aa16 100644 --- a/frontend/core/navigation/src/commonMain/kotlin/at/mocode/frontend/core/navigation/AppScreen.kt +++ b/frontend/core/navigation/src/commonMain/kotlin/at/mocode/frontend/core/navigation/AppScreen.kt @@ -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 } diff --git a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/MainApp.kt b/frontend/shells/meldestelle-portal/src/commonMain/kotlin/MainApp.kt index 1800ba1c..68136786 100644 --- a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/MainApp.kt +++ b/frontend/shells/meldestelle-portal/src/commonMain/kotlin/MainApp.kt @@ -9,6 +9,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import at.mocode.frontend.core.auth.data.AuthTokenManager import at.mocode.frontend.core.auth.presentation.LoginScreen import at.mocode.frontend.core.auth.presentation.LoginViewModel @@ -65,7 +67,7 @@ fun MainApp() { } else { LandingScreen( onPrimaryCta = { navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard)) }, // Takes you to Meldestelle login - onOpenPing = { navigationPort.navigateToScreen(AppScreen.Profile) } // Open the Ping Overview / Status page + onOpenPing = { navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.OrganizerProfile)) } // Nach Login zum OrganizerProfile ) } } @@ -124,9 +126,25 @@ fun MainApp() { is AppScreen.Ping -> PingScreen( viewModel = pingViewModel, - onBack = { navigationPort.navigateToScreen(AppScreen.Profile) } // Always go back to overview + onBack = { navigationPort.navigateToScreen(AppScreen.Profile) } // Zurück zum Profil/Übersicht ) + is AppScreen.OrganizerProfile -> OrganizerProfileScreen( + authTokenManager = authTokenManager, + onLogout = { + authTokenManager.clearToken() + navigationPort.navigateToScreen(AppScreen.Landing) + }, + onNavigateToDashboard = { navigationPort.navigateToScreen(AppScreen.Dashboard) } + ) + + is AppScreen.AuthCallback -> { + // OIDC-Callback: Nach erfolgreichem OAuth-Redirect zum Dashboard navigieren + LaunchedEffect(Unit) { + navigationPort.navigateToScreen(AppScreen.Dashboard) + } + } + is AppScreen.Profile -> AuthStatusScreen( authTokenManager = authTokenManager, onNavigateToLogin = { @@ -144,7 +162,7 @@ fun MainApp() { } ) - else -> {} + else -> { navigationPort.navigateToScreen(AppScreen.Landing) } } } } @@ -194,7 +212,7 @@ private fun LandingScreen( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "Die moderne Turniermeldestelle", + text = "Die moderne Meldestelle", style = MaterialTheme.typography.displayMedium, fontWeight = FontWeight.Bold ) @@ -1188,6 +1206,327 @@ fun TournamentStepBewerbe() { } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun OrganizerProfileScreen( + authTokenManager: AuthTokenManager, + onLogout: () -> Unit, + onNavigateToDashboard: () -> Unit +) { + val authState by authTokenManager.authState.collectAsState() + val scrollState = rememberScrollState() + + // Formular-Felder + var vereinsname by remember { mutableStateOf("URFV Neumarkt") } + var vereinskuerzel by remember { mutableStateOf("URFV") } + var adresse by remember { mutableStateOf("") } + var plz by remember { mutableStateOf("") } + var ort by remember { mutableStateOf("") } + var land by remember { mutableStateOf("Österreich") } + var mapsLink by remember { mutableStateOf("") } + + // Ansprechpersonen + var kontakt1Name by remember { mutableStateOf("") } + var kontakt1Email by remember { mutableStateOf("") } + var kontakt1Telefon by remember { mutableStateOf("") } + var kontakt2Name by remember { mutableStateOf("") } + var kontakt2Email by remember { mutableStateOf("") } + var kontakt2Telefon by remember { mutableStateOf("") } + + // Social / Links + var webseite by remember { mutableStateOf("") } + var facebook by remember { mutableStateOf("") } + var instagram by remember { mutableStateOf("") } + var youtube by remember { mutableStateOf("") } + + // Weitere Infos + var vereinsbeschreibung by remember { mutableStateOf("") } + var bankverbindung by remember { mutableStateOf("") } + var uid by remember { mutableStateOf("") } + + var saveSuccess by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Veranstalter Profil") }, + navigationIcon = { + IconButton(onClick = onNavigateToDashboard) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") + } + }, + actions = { + Text( + text = authState.username ?: "", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = androidx.compose.ui.Modifier.padding(end = 8.dp) + ) + TextButton(onClick = onLogout) { Text("Abmelden") } + } + ) + } + ) { paddingValues -> + Column( + modifier = androidx.compose.ui.Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(scrollState) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + + // --- Logo & Vereinsname --- + Card(modifier = androidx.compose.ui.Modifier.fillMaxWidth()) { + Column(modifier = androidx.compose.ui.Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("Verein / Veranstalter", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + + // Logo Placeholder + Surface( + modifier = androidx.compose.ui.Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceVariant, + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Column( + modifier = androidx.compose.ui.Modifier.padding(24.dp).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("🏆", style = MaterialTheme.typography.displayMedium) + Text("Vereins-/Veranstaltungslogo", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + OutlinedButton(onClick = { /* TODO: File Picker */ }) { + Text("Logo hochladen") + } + } + } + + OutlinedTextField( + value = vereinsname, + onValueChange = { vereinsname = it }, + label = { Text("Vereinsname / Veranstalter") }, + singleLine = true, + modifier = androidx.compose.ui.Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = vereinskuerzel, + onValueChange = { vereinskuerzel = it }, + label = { Text("Kürzel") }, + singleLine = true, + modifier = androidx.compose.ui.Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = vereinsbeschreibung, + onValueChange = { vereinsbeschreibung = it }, + label = { Text("Kurzbeschreibung / Über uns") }, + minLines = 3, + maxLines = 6, + modifier = androidx.compose.ui.Modifier.fillMaxWidth() + ) + } + } + + // --- Adresse --- + Card(modifier = androidx.compose.ui.Modifier.fillMaxWidth()) { + Column(modifier = androidx.compose.ui.Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("Adresse", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + + OutlinedTextField( + value = adresse, + onValueChange = { adresse = it }, + label = { Text("Straße & Hausnummer") }, + singleLine = true, + modifier = androidx.compose.ui.Modifier.fillMaxWidth() + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = plz, + onValueChange = { plz = it }, + label = { Text("PLZ") }, + singleLine = true, + modifier = androidx.compose.ui.Modifier.width(100.dp) + ) + OutlinedTextField( + value = ort, + onValueChange = { ort = it }, + label = { Text("Ort") }, + singleLine = true, + modifier = androidx.compose.ui.Modifier.weight(1f) + ) + } + OutlinedTextField( + value = land, + onValueChange = { land = it }, + label = { Text("Land") }, + singleLine = true, + modifier = androidx.compose.ui.Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = mapsLink, + onValueChange = { mapsLink = it }, + label = { Text("Google Maps / OpenStreetMap Link") }, + placeholder = { Text("https://maps.google.com/...") }, + singleLine = true, + modifier = androidx.compose.ui.Modifier.fillMaxWidth() + ) + } + } + + // --- Ansprechpersonen --- + Card(modifier = androidx.compose.ui.Modifier.fillMaxWidth()) { + Column(modifier = androidx.compose.ui.Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("Ansprechpersonen", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + + Text("Hauptkontakt", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + OutlinedTextField( + value = kontakt1Name, + onValueChange = { kontakt1Name = it }, + label = { Text("Name") }, + singleLine = true, + modifier = androidx.compose.ui.Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = kontakt1Email, + onValueChange = { kontakt1Email = it }, + label = { Text("E-Mail") }, + singleLine = true, + modifier = androidx.compose.ui.Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = kontakt1Telefon, + onValueChange = { kontakt1Telefon = it }, + label = { Text("Telefon / Mobil") }, + singleLine = true, + modifier = androidx.compose.ui.Modifier.fillMaxWidth() + ) + + HorizontalDivider() + + Text("Weiterer Kontakt (optional)", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.secondary) + OutlinedTextField( + value = kontakt2Name, + onValueChange = { kontakt2Name = it }, + label = { Text("Name") }, + singleLine = true, + modifier = androidx.compose.ui.Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = kontakt2Email, + onValueChange = { kontakt2Email = it }, + label = { Text("E-Mail") }, + singleLine = true, + modifier = androidx.compose.ui.Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = kontakt2Telefon, + onValueChange = { kontakt2Telefon = it }, + label = { Text("Telefon / Mobil") }, + singleLine = true, + modifier = androidx.compose.ui.Modifier.fillMaxWidth() + ) + } + } + + // --- Social Media & Links --- + Card(modifier = androidx.compose.ui.Modifier.fillMaxWidth()) { + Column(modifier = androidx.compose.ui.Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("Links & Social Media", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + + OutlinedTextField( + value = webseite, + onValueChange = { webseite = it }, + label = { Text("Webseite") }, + placeholder = { Text("https://www.meinverein.at") }, + singleLine = true, + modifier = androidx.compose.ui.Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = facebook, + onValueChange = { facebook = it }, + label = { Text("Facebook") }, + placeholder = { Text("https://facebook.com/...") }, + singleLine = true, + modifier = androidx.compose.ui.Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = instagram, + onValueChange = { instagram = it }, + label = { Text("Instagram") }, + placeholder = { Text("https://instagram.com/...") }, + singleLine = true, + modifier = androidx.compose.ui.Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = youtube, + onValueChange = { youtube = it }, + label = { Text("YouTube") }, + placeholder = { Text("https://youtube.com/...") }, + singleLine = true, + modifier = androidx.compose.ui.Modifier.fillMaxWidth() + ) + } + } + + // --- Weitere Vereinsdaten --- + Card(modifier = androidx.compose.ui.Modifier.fillMaxWidth()) { + Column(modifier = androidx.compose.ui.Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text("Weitere Informationen", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + + OutlinedTextField( + value = bankverbindung, + onValueChange = { bankverbindung = it }, + label = { Text("IBAN / Bankverbindung") }, + placeholder = { Text("AT12 3456 7890 1234 5678") }, + singleLine = true, + modifier = androidx.compose.ui.Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = uid, + onValueChange = { uid = it }, + label = { Text("UID-Nummer / ZVR-Zahl") }, + singleLine = true, + modifier = androidx.compose.ui.Modifier.fillMaxWidth() + ) + } + } + + // --- Speichern --- + if (saveSuccess) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + modifier = androidx.compose.ui.Modifier.fillMaxWidth() + ) { + Text( + "✓ Profil erfolgreich gespeichert!", + modifier = androidx.compose.ui.Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.bodyLarge + ) + } + } + + Button( + onClick = { + // TODO: Backend-Anbindung (PUT /api/organizer/profile) + saveSuccess = true + }, + modifier = androidx.compose.ui.Modifier.fillMaxWidth().height(52.dp) + ) { + Text("Profil speichern", style = MaterialTheme.typography.titleMedium) + } + + OutlinedButton( + onClick = onNavigateToDashboard, + modifier = androidx.compose.ui.Modifier.fillMaxWidth() + ) { + Text("Zum Dashboard") + } + + Spacer(modifier = androidx.compose.ui.Modifier.height(24.dp)) + } + } +} + @Composable private fun AuthStatusScreen( authTokenManager: AuthTokenManager,