refactor(ui, navigation): implement platform-specific routing and redesign components

- Added platform detection logic `currentPlatform()` in `PlatformType.js.kt`.
- Introduced platform-based behavior for LandingScreen, Dashboard, and Login flow.
- Replaced Row with FlowRow in PingScreen to improve button layout.
- Updated Meldestelle Dashboard with platform-specific headers and authentication checks.
- Adjusted AppHeader to accept `isAuthenticated` and `username` parameters.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-03-19 16:21:23 +01:00
parent 931fe7badb
commit 62264d9e02
15 changed files with 612 additions and 206 deletions
@@ -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<String>? = null // Permissions array
val permissions: List<String>? = null, // Permissions array
val roles: List<String>? = 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<Permission> = emptyList()
val permissions: List<Permission> = emptyList(),
val roles: List<String> = 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<Permission> = _authState.value.permissions
/**
* Get current user roles (Keycloak Realm Roles)
*/
fun getRoles(): List<String> = _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<JwtPayload>(payloadJson)
jsonParser.decodeFromString<JwtPayload>(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<String>? = basicPayload?.roles
var username: String? = basicPayload?.username
try {
val jsonObject = jsonParser.decodeFromString<JsonObject>(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
@@ -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()
@@ -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<String> = 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
)
)
}
}
}
@@ -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 = {
@@ -0,0 +1,8 @@
package at.mocode.frontend.core.domain
enum class PlatformType {
WEB,
DESKTOP
}
expect fun currentPlatform(): PlatformType
@@ -0,0 +1,3 @@
package at.mocode.frontend.core.domain
actual fun currentPlatform(): PlatformType = PlatformType.WEB
@@ -0,0 +1,3 @@
package at.mocode.frontend.core.domain
actual fun currentPlatform(): PlatformType = PlatformType.DESKTOP
@@ -0,0 +1,3 @@
package at.mocode.frontend.core.domain
actual fun currentPlatform(): PlatformType = PlatformType.WEB
@@ -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