diff --git a/config/docker/keycloak/meldestelle-realm.json b/config/docker/keycloak/meldestelle-realm.json index b88ede80..f601d45a 100644 --- a/config/docker/keycloak/meldestelle-realm.json +++ b/config/docker/keycloak/meldestelle-realm.json @@ -61,6 +61,12 @@ "composite": false, "clientRole": false }, + { + "name": "ORGANIZER", + "description": "Veranstalter role for managing tournaments", + "composite": false, + "clientRole": false + }, { "name": "MONITORING", "description": "Monitoring role for system health checks", @@ -301,6 +307,26 @@ "ADMIN" ] } + }, + { + "username": "urfv_neumarkt", + "enabled": true, + "emailVerified": true, + "firstName": "URFV", + "lastName": "Neumarkt", + "email": "office@urfv-neumarkt.at", + "credentials": [ + { + "type": "password", + "value": "Turnier#2026", + "temporary": false + } + ], + "realmRoles": [ + "USER", + "ORGANIZER" + ], + "clientRoles": {} } ], "groups": [], diff --git a/docs/Neumarkt2026/Master-Desktop-Dashboard_2026-03-19_14-27.png b/docs/Neumarkt2026/Master-Desktop-Dashboard_2026-03-19_14-27.png new file mode 100644 index 00000000..ffb55090 Binary files /dev/null and b/docs/Neumarkt2026/Master-Desktop-Dashboard_2026-03-19_14-27.png differ 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 35fcb55c..3eb72a5a 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 @@ -4,7 +4,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json +import kotlinx.serialization.json.* import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.time.ExperimentalTime @@ -49,7 +49,8 @@ data class JwtPayload( val exp: Long? = null, // Expiration timestamp val iat: Long? = null, // Issued at timestamp val iss: String? = null, // Issuer - val permissions: List? = null // Permissions array + val permissions: List? = null, // Permissions array + val roles: List? = null // Realm Roles from Keycloak ) /** @@ -60,7 +61,8 @@ data class AuthState( val token: String? = null, val userId: String? = null, val username: String? = null, - val permissions: List = emptyList() + val permissions: List = emptyList(), + val roles: List = emptyList() // Added roles ) /** @@ -73,6 +75,9 @@ data class AuthState( @Suppress("unused") class AuthTokenManager { + // Shared Json instance to avoid redundant creation + private val jsonParser = Json { ignoreUnknownKeys = true } + private var currentToken: String? = null private var tokenPayload: JwtPayload? = null @@ -96,12 +101,15 @@ class AuthTokenManager { } } ?: emptyList() + val roles = tokenPayload?.roles ?: emptyList() + _authState.value = AuthState( isAuthenticated = true, token = token, userId = tokenPayload?.sub, username = tokenPayload?.username, - permissions = permissions + permissions = permissions, + roles = roles ) } @@ -150,6 +158,11 @@ class AuthTokenManager { */ fun getPermissions(): List = _authState.value.permissions + /** + * Get current user roles (Keycloak Realm Roles) + */ + fun getRoles(): List = _authState.value.roles + /** * Check if the user has a specific permission */ @@ -157,6 +170,13 @@ class AuthTokenManager { return _authState.value.permissions.contains(permission) } + /** + * Check if the user has a specific role + */ + fun hasRole(role: String): Boolean { + return _authState.value.roles.contains(role) + } + /** * Check if the user has any of the specified permissions */ @@ -222,7 +242,9 @@ class AuthTokenManager { /** * Check if the user is admin (has deleted permissions) */ - fun isAdmin(): Boolean = canDelete() + fun isAdmin(): Boolean = hasRole("ADMIN") + + fun isOrganizer(): Boolean = hasRole("ORGANIZER") /** * Check if the token expires within specified minutes @@ -252,28 +274,48 @@ class AuthTokenManager { // First, try to parse with a standard approach val basicPayload = try { - Json.decodeFromString(payloadJson) + jsonParser.decodeFromString(payloadJson) } catch (e: Exception) { - // If that fails, extract manually + // If that fails, try to extract it manually null } - // If basic parsing succeeded and has permissions, return it - if (basicPayload != null && basicPayload.permissions != null) { - return basicPayload + // Try to parse JSON to extract roles which are often nested in realm_access.roles + var roles: List? = basicPayload?.roles + var username: String? = basicPayload?.username + + try { + val jsonObject = jsonParser.decodeFromString(payloadJson) + + if (roles == null) { + // Try Keycloak specific format: "realm_access": { "roles": ["ADMIN", "USER"] } + val realmAccess = jsonObject["realm_access"]?.jsonObject + val rolesArray = realmAccess?.get("roles")?.jsonArray + roles = rolesArray?.map { it.jsonPrimitive.content } + } + + if (username == null) { + // try preferred_username + username = jsonObject["preferred_username"]?.jsonPrimitive?.content + } + + } catch (e: Exception) { + // Ignore } - // Otherwise, extract permissions manually from a JSON string - val permissions = extractPermissionsFromJson(payloadJson) - // Return payload with manually extracted permissions + // Extract permissions manually from a JSON string if needed + val permissions = basicPayload?.permissions ?: extractPermissionsFromJson(payloadJson) + + // Return payload JwtPayload( sub = basicPayload?.sub, - username = basicPayload?.username, + username = username, exp = basicPayload?.exp, iat = basicPayload?.iat, iss = basicPayload?.iss, - permissions = permissions + permissions = permissions, + roles = roles ) } catch (e: Exception) { // Failed to parse - token might be invalid format 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 21dffad9..b0fdc745 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 @@ -11,8 +11,8 @@ import androidx.compose.material3.* import androidx.compose.runtime.* 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.focus.FocusDirection +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -28,7 +28,7 @@ fun LoginScreen( onBack: () -> Unit = {} // New callback for back navigation ) { val uiState by viewModel.uiState.collectAsState() - val passwordFocusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current var passwordVisible by remember { mutableStateOf(false) } Scaffold( @@ -64,8 +64,9 @@ fun LoginScreen( imeAction = ImeAction.Next ), keyboardActions = KeyboardActions( - onNext = { passwordFocusRequester.requestFocus() } + onNext = { focusManager.moveFocus(FocusDirection.Next) } ), + singleLine = true, modifier = Modifier .fillMaxWidth() .padding(bottom = 16.dp) @@ -98,14 +99,15 @@ fun LoginScreen( ), keyboardActions = KeyboardActions( onDone = { + focusManager.clearFocus() if (uiState.canLogin) { viewModel.login() } } ), + singleLine = true, modifier = Modifier .fillMaxWidth() - .focusRequester(passwordFocusRequester) .padding(bottom = 24.dp) ) @@ -131,7 +133,10 @@ fun LoginScreen( // Login button Button( - onClick = { viewModel.login() }, + onClick = { + focusManager.clearFocus() + viewModel.login() + }, enabled = uiState.canLogin && !uiState.isLoading, modifier = Modifier .fillMaxWidth() diff --git a/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/AppHeader.kt b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/AppHeader.kt index 2154b901..c1f33e3a 100644 --- a/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/AppHeader.kt +++ b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/AppHeader.kt @@ -1,74 +1,67 @@ package at.mocode.frontend.core.designsystem.components +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp -@OptIn(ExperimentalMaterial3Api::class) @Composable fun AppHeader( - title: String, - onNavigateToPing: (() -> Unit)? = null, + isAuthenticated: Boolean, + username: String?, onNavigateToLogin: (() -> Unit)? = null, - onLogout: (() -> Unit)? = null, - isAuthenticated: Boolean = false, - username: String? = null, - userPermissions: List = emptyList() + onLogout: (() -> Unit)? = null ) { - TopAppBar( - title = { + Surface( + color = MaterialTheme.colorScheme.surface, + shadowElevation = 2.dp, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { Text( - text = title, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold + text = "Meldestelle", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary ) - }, - actions = { - // Ping Service button - onNavigateToPing?.let { navigateAction -> - TextButton( - onClick = navigateAction - ) { - Text("Ping Service") - } - } - // Authentication buttons - if (isAuthenticated) { - // Show username with admin indicator if user has deleted permissions - username?.let { user -> - val isAdmin = userPermissions.any { it.contains("DELETE") } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (isAuthenticated) { + // Show the username and logout button Text( - text = if (isAdmin) "👑 Hallo, $user (Admin)" else "Hallo, $user", + text = "Angemeldet als: ${username ?: "Admin"}", style = MaterialTheme.typography.bodyMedium, - color = if (isAdmin) - MaterialTheme.colorScheme.tertiary - else - MaterialTheme.colorScheme.onPrimaryContainer + color = MaterialTheme.colorScheme.onSurfaceVariant ) - } - onLogout?.let { logoutAction -> - TextButton( - onClick = logoutAction - ) { - Text("Abmelden") + onLogout?.let { logoutAction -> + TextButton(onClick = logoutAction) { + Text("Abmelden") + } } - } - } else { - // Show the login button - onNavigateToLogin?.let { loginAction -> - TextButton( - onClick = loginAction - ) { - Text("Anmelden") + } else { + // Show the login button + onNavigateToLogin?.let { loginAction -> + Button( + onClick = loginAction + ) { + Text("Login") + } } } } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer - ) - ) + } + } } diff --git a/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/AppScaffold.kt b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/AppScaffold.kt index b73c63e0..535d6a48 100644 --- a/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/AppScaffold.kt +++ b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/AppScaffold.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.Modifier @Composable fun AppScaffold( header: @Composable () -> Unit = { - AppHeader(title = "Meldestelle") + AppHeader(isAuthenticated = false, username = null) }, content: @Composable (PaddingValues) -> Unit, footer: @Composable () -> Unit = { diff --git a/frontend/core/domain/src/commonMain/kotlin/at/mocode/frontend/core/domain/PlatformType.kt b/frontend/core/domain/src/commonMain/kotlin/at/mocode/frontend/core/domain/PlatformType.kt new file mode 100644 index 00000000..772bf35c --- /dev/null +++ b/frontend/core/domain/src/commonMain/kotlin/at/mocode/frontend/core/domain/PlatformType.kt @@ -0,0 +1,8 @@ +package at.mocode.frontend.core.domain + +enum class PlatformType { + WEB, + DESKTOP +} + +expect fun currentPlatform(): PlatformType diff --git a/frontend/core/domain/src/jsMain/kotlin/at/mocode/frontend/core/domain/PlatformType.js.kt b/frontend/core/domain/src/jsMain/kotlin/at/mocode/frontend/core/domain/PlatformType.js.kt new file mode 100644 index 00000000..6f4be043 --- /dev/null +++ b/frontend/core/domain/src/jsMain/kotlin/at/mocode/frontend/core/domain/PlatformType.js.kt @@ -0,0 +1,3 @@ +package at.mocode.frontend.core.domain + +actual fun currentPlatform(): PlatformType = PlatformType.WEB diff --git a/frontend/core/domain/src/jvmMain/kotlin/at/mocode/frontend/core/domain/PlatformType.jvm.kt b/frontend/core/domain/src/jvmMain/kotlin/at/mocode/frontend/core/domain/PlatformType.jvm.kt new file mode 100644 index 00000000..ad32e6ce --- /dev/null +++ b/frontend/core/domain/src/jvmMain/kotlin/at/mocode/frontend/core/domain/PlatformType.jvm.kt @@ -0,0 +1,3 @@ +package at.mocode.frontend.core.domain + +actual fun currentPlatform(): PlatformType = PlatformType.DESKTOP diff --git a/frontend/core/domain/src/wasmJsMain/kotlin/at/mocode/frontend/core/domain/PlatformType.wasmJs.kt b/frontend/core/domain/src/wasmJsMain/kotlin/at/mocode/frontend/core/domain/PlatformType.wasmJs.kt new file mode 100644 index 00000000..6f4be043 --- /dev/null +++ b/frontend/core/domain/src/wasmJsMain/kotlin/at/mocode/frontend/core/domain/PlatformType.wasmJs.kt @@ -0,0 +1,3 @@ +package at.mocode.frontend.core.domain + +actual fun currentPlatform(): PlatformType = PlatformType.WEB 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 264ba957..bfbc7dda 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 @@ -5,7 +5,10 @@ sealed class AppScreen(val route: String) { data object Home : AppScreen("/home") data object Dashboard : AppScreen("/dashboard") data object CreateTournament : AppScreen("/tournament/create") // Neuer Screen - data object Login : AppScreen(Routes.LOGIN) + + // Login now accepts an optional returnTo screen to determine where to go after success + data class Login(val returnTo: AppScreen? = null) : AppScreen(Routes.LOGIN) + data object Ping : AppScreen("/ping") data object Profile : AppScreen("/profile") data object AuthCallback : AppScreen("/auth/callback") @@ -17,7 +20,7 @@ sealed class AppScreen(val route: String) { "/home" -> Home "/dashboard" -> Dashboard "/tournament/create" -> CreateTournament - Routes.LOGIN, Routes.Auth.LOGIN -> Login + Routes.LOGIN, Routes.Auth.LOGIN -> Login() "/ping" -> Ping "/profile" -> Profile "/auth/callback" -> AuthCallback diff --git a/frontend/features/ping-feature/build.gradle.kts b/frontend/features/ping-feature/build.gradle.kts index 193a62b4..7da347ec 100644 --- a/frontend/features/ping-feature/build.gradle.kts +++ b/frontend/features/ping-feature/build.gradle.kts @@ -28,6 +28,7 @@ kotlin { implementation(projects.frontend.core.designSystem) implementation(projects.frontend.core.sync) implementation(projects.frontend.core.localDb) + implementation(projects.frontend.core.auth) // Added auth module for AuthTokenManager implementation(libs.sqldelight.coroutines) implementation(projects.frontend.core.domain) @@ -43,6 +44,7 @@ kotlin { implementation(libs.bundles.compose.common) implementation(libs.koin.core) + implementation(libs.koin.compose) // Added koin.compose for koinInject } commonTest.dependencies { diff --git a/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/presentation/PingScreen.kt b/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/presentation/PingScreen.kt index 87e626ca..24dc5f28 100644 --- a/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/presentation/PingScreen.kt +++ b/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/presentation/PingScreen.kt @@ -133,9 +133,11 @@ private fun StatusBadge(text: String, color: Color) { @Composable private fun ActionToolbar(viewModel: PingViewModel) { // Wrap buttons to avoid overflow on small screens - Row( + @OptIn(ExperimentalLayoutApi::class) + FlowRow( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingXS) + horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingXS), + verticalArrangement = Arrangement.spacedBy(Dimens.SpacingXS) ) { DenseButton(text = "Simple", onClick = { viewModel.performSimplePing() }) DenseButton(text = "Enhanced", onClick = { viewModel.performEnhancedPing() }) diff --git a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/MainApp.kt b/frontend/shells/meldestelle-portal/src/commonMain/kotlin/MainApp.kt index 616d1604..1800ba1c 100644 --- a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/MainApp.kt +++ b/frontend/shells/meldestelle-portal/src/commonMain/kotlin/MainApp.kt @@ -5,13 +5,17 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +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 at.mocode.frontend.core.auth.data.AuthTokenManager import at.mocode.frontend.core.auth.presentation.LoginScreen import at.mocode.frontend.core.auth.presentation.LoginViewModel import at.mocode.frontend.core.designsystem.components.AppFooter import at.mocode.frontend.core.designsystem.theme.AppTheme +import at.mocode.frontend.core.domain.PlatformType +import at.mocode.frontend.core.domain.currentPlatform import at.mocode.frontend.core.navigation.AppScreen import at.mocode.ping.feature.presentation.PingScreen import at.mocode.ping.feature.presentation.PingViewModel @@ -38,27 +42,58 @@ fun MainApp() { val pingViewModel: PingViewModel = koinViewModel() val loginViewModel: LoginViewModel = koinViewModel() - when (currentScreen) { - is AppScreen.Landing -> LandingScreen( - onPrimaryCta = { navigationPort.navigateToScreen(AppScreen.Login) } - ) + when (val screen = currentScreen) { + is AppScreen.Landing -> { + if (currentPlatform() == PlatformType.DESKTOP) { + // Master Desktop: MUSS eingeloggt sein, um irgendwas zu sehen. + // Wir leiten für den POC erst mal hart auf den Login, wenn nicht eingeloggt + val authState = authTokenManager.authState.collectAsState().value + if (authState.isAuthenticated) { + DashboardScreen( + authTokenManager = authTokenManager, + onLogout = { + authTokenManager.clearToken() + navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard)) + }, + onCreateTournament = { navigationPort.navigateToScreen(AppScreen.CreateTournament) } + ) + } else { + LaunchedEffect(Unit) { + navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard)) + } + } + } 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 + ) + } + } is AppScreen.Dashboard -> DashboardScreen( authTokenManager = authTokenManager, - onOpenPing = { navigationPort.navigateToScreen(AppScreen.Ping) }, onLogout = { authTokenManager.clearToken() - navigationPort.navigateToScreen(AppScreen.Landing) - } + if (currentPlatform() == PlatformType.DESKTOP) { + navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard)) + } else { + navigationPort.navigateToScreen(AppScreen.Landing) + } + }, + onCreateTournament = { navigationPort.navigateToScreen(AppScreen.CreateTournament) } ) is AppScreen.Home -> DashboardScreen( // Route /home to Dashboard for now authTokenManager = authTokenManager, - onOpenPing = { navigationPort.navigateToScreen(AppScreen.Ping) }, onLogout = { authTokenManager.clearToken() - navigationPort.navigateToScreen(AppScreen.Landing) - } + if (currentPlatform() == PlatformType.DESKTOP) { + navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard)) + } else { + navigationPort.navigateToScreen(AppScreen.Landing) + } + }, + onCreateTournament = { navigationPort.navigateToScreen(AppScreen.CreateTournament) } ) is AppScreen.CreateTournament -> CreateTournamentScreen( @@ -66,20 +101,47 @@ fun MainApp() { onSave = { navigationPort.navigateToScreen(AppScreen.Dashboard) } // Later we go to tournament detail ) - is AppScreen.Login -> LoginScreen( - viewModel = loginViewModel, - onLoginSuccess = { navigationPort.navigateToScreen(AppScreen.Dashboard) }, - onBack = { navigationPort.navigateToScreen(AppScreen.Landing) } - ) + is AppScreen.Login -> { + // wir prüfen hier, woher wir kamen (falls wir das im State hätten) + // Im Moment: nach erfolgreichem Login zum returnTo-Screen navigieren, Default ist Dashboard + LoginScreen( + viewModel = loginViewModel, + onLoginSuccess = { + val targetScreen = screen.returnTo ?: AppScreen.Dashboard + navigationPort.navigateToScreen(targetScreen) + }, + onBack = { + if (currentPlatform() == PlatformType.DESKTOP) { + // No real back from login in desktop if forced, but let's go to Dashboard empty state + // This is a bit tricky, but for PoC we just clear + navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard)) + } else { + navigationPort.navigateToScreen(AppScreen.Landing) + } + } + ) + } is AppScreen.Ping -> PingScreen( viewModel = pingViewModel, - onBack = { navigationPort.navigateToScreen(AppScreen.Dashboard) } + onBack = { navigationPort.navigateToScreen(AppScreen.Profile) } // Always go back to overview ) is AppScreen.Profile -> AuthStatusScreen( authTokenManager = authTokenManager, - onBackToHome = { navigationPort.navigateToScreen(AppScreen.Dashboard) } + onNavigateToLogin = { + // Hier nutzen wir den dynamischen Return-Pfad: Nach dem Login soll der User + // exakt auf dieses AuthStatusScreen/Profile zurückkehren. + navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Profile)) + }, + onNavigateToPing = { navigationPort.navigateToScreen(AppScreen.Ping) }, + onBackToHome = { + if (currentPlatform() == PlatformType.DESKTOP) { + navigationPort.navigateToScreen(AppScreen.Dashboard) + } else { + navigationPort.navigateToScreen(AppScreen.Landing) + } + } ) else -> {} @@ -90,7 +152,8 @@ fun MainApp() { @Composable private fun LandingScreen( - onPrimaryCta: () -> Unit + onPrimaryCta: () -> Unit, + onOpenPing: () -> Unit ) { val scrollState = rememberScrollState() @@ -142,41 +205,6 @@ private fun LandingScreen( ) } - // --- AKTUELLE TURNIERE SECTION --- - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 40.dp), - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - Text("Aktuelle Turniere", style = MaterialTheme.typography.headlineMedium) - - // Dummy Daten basierend auf Neumarkt 2026 - val turniere = listOf( - TournamentData( - id = "26128", - date = "25. APRIL 2026", - title = "CSN-C NEU CSNP-C NEU", - location = "NEUMARKT/M., OÖ" - ), - TournamentData( - id = "26129", - date = "26. APRIL 2026", - title = "CDN-C NEU CDNP-C NEU", - location = "NEUMARKT/M., OÖ" - ) - ) - - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth() - ) { - turniere.forEach { turnier -> - TournamentCard(turnier) - } - } - } - // Manifest / Intro Surface(color = MaterialTheme.colorScheme.surfaceVariant) { Column( @@ -233,6 +261,18 @@ private fun LandingScreen( } } + // Ping Service Link + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedButton(onClick = onOpenPing) { + Text("System Status (Ping-Service)") + } + } + // Footer AppFooter() } @@ -355,10 +395,24 @@ private fun FeatureCard(number: String, title: String, body: String) { @Composable private fun DashboardScreen( authTokenManager: AuthTokenManager, - onOpenPing: () -> Unit, - onLogout: () -> Unit + onLogout: () -> Unit, + onCreateTournament: () -> Unit ) { val authState by authTokenManager.authState.collectAsState() + val scrollState = rememberScrollState() + val isDesktop = currentPlatform() == PlatformType.DESKTOP + + // Security Check für das Dashboard + if (!authState.isAuthenticated) { + // Wenn nicht eingeloggt, zeige nur eine leere Seite oder einen Hinweis an + // (Die Umleitung zum Login passiert in MainApp bzw. LaunchedEffect) + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return + } + + val isAdmin = authTokenManager.isAdmin() Column( modifier = Modifier.fillMaxSize() @@ -375,7 +429,7 @@ private fun DashboardScreen( horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = "Meldestelle Dashboard", + text = if (isDesktop) "Master-Meldestelle Steuerungszentrale" else if (isAdmin) "Admin-Dashboard (Web)" else "Veranstalter-Dashboard", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold ) @@ -396,112 +450,351 @@ private fun DashboardScreen( } // Main Content Area - Row( - modifier = Modifier.fillMaxSize().padding(24.dp), - horizontalArrangement = Arrangement.spacedBy(24.dp) + Column( + modifier = Modifier.fillMaxSize().padding(24.dp).verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(24.dp) ) { - // Left Column (Turniere) - Column( - modifier = Modifier.weight(2f), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text("Meine Turniere", style = MaterialTheme.typography.headlineSmall) - // Dummy Turniere für die Meldestelle - val turniere = listOf( - TournamentData( - id = "26128", - date = "25. APRIL 2026", - title = "CSN-C NEU CSNP-C NEU", - location = "NEUMARKT/M., OÖ" - ), - TournamentData( - id = "26129", - date = "26. APRIL 2026", - title = "CDN-C NEU CDNP-C NEU", - location = "NEUMARKT/M., OÖ" + if (isDesktop && isAdmin) { + // DESKTOP VIEW - STEUERUNGSZENTRALE FÜR DEN ADMIN (DICH) + // Neues Turnier anlegen Button + OutlinedButton( + onClick = onCreateTournament, + modifier = Modifier.fillMaxWidth().height(64.dp) + ) { + Text( + text = "+ neues Turnier anlegen", + style = MaterialTheme.typography.titleMedium ) + } + + // Meine Turniere Section + Text( + text = "Alle verwalteten Turniere", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold ) - turniere.forEach { turnier -> + // Filters (Mockup) + Surface( + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = MaterialTheme.shapes.small, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Zeitraum:", style = MaterialTheme.typography.bodyMedium) + OutlinedTextField( + value = "März", + onValueChange = {}, + modifier = Modifier.width(100.dp).height(48.dp), + singleLine = true + ) + Text("bis", style = MaterialTheme.typography.bodyMedium) + OutlinedTextField( + value = "Dezember", + onValueChange = {}, + modifier = Modifier.width(120.dp).height(48.dp), + singleLine = true + ) + OutlinedTextField( + value = "2026", + onValueChange = {}, + modifier = Modifier.width(80.dp).height(48.dp), + singleLine = true + ) + OutlinedTextField( + value = "Bundesland", + onValueChange = {}, + modifier = Modifier.width(150.dp).height(48.dp), + singleLine = true + ) + Button( + onClick = {}, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE67E22)) + ) { // Orange color + Text("Anzeigen") + } + } + Text("Zusätzliche Filter auf Suchergebnisse:", style = MaterialTheme.typography.bodyMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = "Veranstalter (Verein)", + onValueChange = {}, + modifier = Modifier.width(180.dp).height(48.dp), + singleLine = true + ) + OutlinedTextField( + value = "Ort", + onValueChange = {}, + modifier = Modifier.width(120.dp).height(48.dp), + singleLine = true + ) + OutlinedTextField( + value = "Sparte", + onValueChange = {}, + modifier = Modifier.width(120.dp).height(48.dp), + singleLine = true + ) + OutlinedTextField( + value = "Turnierart", + onValueChange = {}, + modifier = Modifier.width(120.dp).height(48.dp), + singleLine = true + ) + } + } + } + + // Desktop Tournament Card (Steuerungszentrale Ansicht) Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) + border = androidx.compose.foundation.BorderStroke(2.dp, MaterialTheme.colorScheme.outlineVariant), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + // Left Side: Logo and Text + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.weight(1f) + ) { + // Logo Placeholder + Surface( + modifier = Modifier.size(120.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = MaterialTheme.shapes.small + ) { + Box(contentAlignment = Alignment.Center) { + Text("NEUMARKT\nLOGO", textAlign = TextAlign.Center, style = MaterialTheme.typography.bodySmall) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("CDN-C NEU CDNP-C", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text( + "Veranstalter: URFV Neumarkt", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + Text("NEUMARKT/M., OÖ 26. APRIL 2026", style = MaterialTheme.typography.bodyMedium) + Text("Turnier-Nr.: 26129", style = MaterialTheme.typography.bodyMedium) + } + } + + // Right Side: Toggles (Statusanzeigen für den Admin) + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.width(300.dp)) { + ToggleRow("Meldestelle-Desktop online", isOnline = true, isInteractive = false) + ToggleRow("Nennsystem online", isOnline = true, isInteractive = true) + ToggleRow("Start- Ergebnislisten online", isOnline = true, isInteractive = true) + + Spacer(modifier = Modifier.height(4.dp)) + OutlinedButton( + onClick = { /* Link kopieren oder Email Dialog öffnen */ }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Veranstalter-Link senden") + } + } + } + } + + } else if (isDesktop && !isAdmin) { + // DESKTOP VIEW - VERANSTALTER (Meldestelle am Platz) + Text( + "Willkommen in der Meldestellen-Software für Turnier 26129", + style = MaterialTheme.typography.headlineMedium + ) + Text("Bitte initialisieren Sie die lokale Datenbank oder importieren Sie einen Stand vom USB-Stick.") + Spacer(Modifier.height(16.dp)) + Button(onClick = onCreateTournament) { // This acts as the "Setup Wizard" + Text("Turnier initialisieren / Importieren") + } + } else if (!isDesktop && isAdmin) { + // WEB VIEW - ADMIN PORTAL (Deine Steuerungszentrale im Web) + Text( + text = "Alle verwalteten Turniere", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) + ) { Row( modifier = Modifier.padding(16.dp).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Column { - Text(turnier.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Text("Nr: ${turnier.id} | ${turnier.date}", style = MaterialTheme.typography.bodyMedium) + Text( + "CDN-C NEU CDNP-C Neumarkt", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text("Veranstalter: URFV Neumarkt", style = MaterialTheme.typography.bodyMedium) + Text("Nr: 26129 | 26. APRIL 2026", style = MaterialTheme.typography.bodyMedium) } - Button(onClick = { /* TODO: Open Meldestellen Cockpit for this tournament */ }) { - Text("Meldestelle öffnen") + Button(onClick = { /* TODO: Download Trigger for Master App */ }) { + Text("Master-Desktop-App herunterladen") } } } - } - - // DEIN NEUES KONZEPT: Download Desktop App statt "Neues Turnier anlegen" im Web + } else { + // WEB VIEW - VERANSTALTER PORTAL + // Top: Aktuelles Turnier & Download Card( - modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer) ) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - "Master-Meldestelle Desktop App", + "Aktuelles Turnier: CDN-C NEU CDNP-C Neumarkt", + style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSecondaryContainer ) Text( - "Für die Turnieranlage, den ZNS-Import und vollen Offline-Betrieb (USB-Sync) laden Sie bitte die Desktop-Anwendung herunter.", + "Turnier-Nr.: 26129 | 26. APRIL 2026", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSecondaryContainer ) + Spacer(Modifier.height(8.dp)) + + Text( + "Bitte laden Sie die Desktop-Anwendung herunter, um die Meldestelle lokal an Ihrem Turnierplatz zu betreiben.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(top = 8.dp) ) { - Button(onClick = { /* TODO: Download Trigger */ }) { - Text("Download für Windows (.exe)") + Button(onClick = { /* TODO: Download Trigger for generic app */ }) { + Text("Meldestelle-App herunterladen (.exe)") } - // Status Anzeige - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + // Lizenz-Key Anzeige Surface( - modifier = Modifier.size(12.dp), shape = MaterialTheme.shapes.small, - color = MaterialTheme.colorScheme.error - ) {} - Text("Desktop App derzeit Offline", style = MaterialTheme.typography.labelMedium) + color = MaterialTheme.colorScheme.surface, + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outline) + ) { + Text( + "Ihr Aktivierungs-Code: X7F9-K2M4", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + style = MaterialTheme.typography.labelLarge + ) + } + } + } + } + + // Middle: Historie (Meine Turniere) + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + Text("Turnier-Historie (Archiv)", style = MaterialTheme.typography.headlineSmall) + + // Dummy Turniere aus der Vergangenheit + val turniere = listOf( + TournamentData( + id = "25044", + date = "24. APRIL 2025", + title = "CSN-C CSNP-C Neumarkt", + location = "NEUMARKT/M., OÖ" + ), + TournamentData( + id = "24012", + date = "28. APRIL 2024", + title = "CDN-C CDNP-C Neumarkt", + location = "NEUMARKT/M., OÖ" + ) + ) + + turniere.forEach { turnier -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text(turnier.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text("Nr: ${turnier.id} | ${turnier.date}", style = MaterialTheme.typography.bodyMedium) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { /* TODO: Open PDF Archive */ }) { + Text("Ergebnislisten (PDF)") + } + OutlinedButton(onClick = { /* TODO: Open Stats */ }) { + Text("Statistiken") + } + } } } } } } + } + } +} - // Right Column (System / Tools) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text("System & Tools", style = MaterialTheme.typography.headlineSmall) +@Composable +private fun ToggleRow(label: String, isOnline: Boolean, isInteractive: Boolean = false) { + Surface( + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = MaterialTheme.shapes.small, + modifier = Modifier.fillMaxWidth().height(40.dp), + color = if (isInteractive) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceVariant + ) { + Row( + modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(label, style = MaterialTheme.typography.bodyMedium) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // Fake status circle + val statusColor = if (isOnline) Color(0xFF4CAF50) else Color(0xFF9E9E9E) + Surface( + modifier = Modifier.size(16.dp), + shape = androidx.compose.foundation.shape.CircleShape, + color = statusColor + ) {} - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - OutlinedButton( - onClick = onOpenPing, - modifier = Modifier.fillMaxWidth() - ) { - Text("Ping-Service (System Status)") + // Fake switch / Status text + if (isInteractive) { + Surface( + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + modifier = Modifier.width(40.dp).height(24.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Text(if (isOnline) "on" else "off", style = MaterialTheme.typography.labelSmall) } } + } else { + Text( + if (isOnline) "Online" else "Offline", + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.width(40.dp), + textAlign = TextAlign.Center + ) } } - } + } } } @@ -898,6 +1191,8 @@ fun TournamentStepBewerbe() { @Composable private fun AuthStatusScreen( authTokenManager: AuthTokenManager, + onNavigateToLogin: () -> Unit, + onNavigateToPing: () -> Unit, onBackToHome: () -> Unit ) { val authState by authTokenManager.authState.collectAsState() @@ -907,24 +1202,45 @@ private fun AuthStatusScreen( .padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text("Profil / Status", style = MaterialTheme.typography.headlineMedium) - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - if (authState.isAuthenticated) { - Text("Du bist als ${authState.username ?: authState.userId ?: "unbekannt"} angemeldet.") - Spacer(Modifier.height(8.dp)) - Button(onClick = { - authTokenManager.clearToken() - onBackToHome() - }) { Text("Abmelden") } + Text("Ping-Service / System Status", style = MaterialTheme.typography.headlineMedium) + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + if (authState.isAuthenticated) { + Text( + "Du bist angemeldet als: ${authState.username ?: authState.userId ?: "unbekannt"}", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) Spacer(Modifier.height(8.dp)) - OutlinedButton(onClick = onBackToHome) { Text("Zurück zum Dashboard") } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Button(onClick = onNavigateToPing) { + Text("Ping-Service Tests durchführen") + } + OutlinedButton(onClick = { + authTokenManager.clearToken() + }) { Text("Abmelden") } + } + } else { - Text("Nicht angemeldet.") + Text( + "Du bist abgemeldet.", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error + ) Spacer(Modifier.height(8.dp)) - Button(onClick = onBackToHome) { Text("Zurück") } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Button(onClick = onNavigateToLogin) { + Text("Login") + } + OutlinedButton(onClick = onNavigateToPing) { + Text("Ping-Service (eingeschränkt testen)") + } + } } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + OutlinedButton(onClick = onBackToHome) { Text("Zurück zur Startseite") } } } } diff --git a/frontend/shells/meldestelle-portal/src/jvmMain/kotlin/main.kt b/frontend/shells/meldestelle-portal/src/jvmMain/kotlin/main.kt index 820fde1c..8a1f4e0b 100644 --- a/frontend/shells/meldestelle-portal/src/jvmMain/kotlin/main.kt +++ b/frontend/shells/meldestelle-portal/src/jvmMain/kotlin/main.kt @@ -40,7 +40,7 @@ fun main() = application { } Window( onCloseRequest = ::exitApplication, - title = "Equest-Events Master Desktop", + title = "Master Desktop", state = WindowState(width = 1200.dp, height = 800.dp) ) { MainApp()