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:
+57
-15
@@ -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
-6
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user