refactor(auth): migrate Auth module from feature to core package
- Reorganized `auth-feature` into `core/auth` to improve architecture and modularity. - Removed unused PKCE and OAuth callback utilities (`AuthCallbackParams`, `OAuthPkceService`). - Updated imports and adjusted build scripts to reflect new module structure. - Refactored `LoginScreen` and `PingScreen` to include `onBack` functionality in top bars for improved navigation. - Corrected sync endpoint in `PingSyncService`.
This commit is contained in:
@@ -1,111 +0,0 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
/**
|
||||
* Dieses Modul kapselt die gesamte UI und Logik für das Authentication-Feature.
|
||||
* Es kennt seine eigenen technischen Abhängigkeiten (Ktor, Coroutines)
|
||||
* und den UI-Baukasten (common-ui), aber es kennt keine anderen Features.
|
||||
*/
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
alias(libs.plugins.kotlinSerialization)
|
||||
}
|
||||
|
||||
group = "at.mocode.clients"
|
||||
version = "1.0.0"
|
||||
|
||||
kotlin {
|
||||
// Toolchain is now handled centrally in the root build.gradle.kts
|
||||
|
||||
jvm()
|
||||
|
||||
js {
|
||||
browser {
|
||||
testTask {
|
||||
enabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wasm vorerst deaktiviert
|
||||
/*
|
||||
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
||||
wasmJs {
|
||||
browser()
|
||||
}
|
||||
*/
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
// UI Kit (Design System)
|
||||
implementation(projects.frontend.core.designSystem)
|
||||
|
||||
// Shared Konfig & Utilities (AppConfig + BuildConfig)
|
||||
implementation(projects.frontend.shared)
|
||||
|
||||
// Network core (provides apiClient + TokenProvider)
|
||||
implementation(projects.frontend.core.network)
|
||||
|
||||
// Compose dependencies (Core UI)
|
||||
implementation(compose.runtime)
|
||||
implementation(compose.foundation)
|
||||
implementation(compose.material3)
|
||||
implementation(compose.ui)
|
||||
implementation(compose.components.resources)
|
||||
implementation(compose.materialIconsExtended)
|
||||
|
||||
// Bundles (Cleaned up dependencies)
|
||||
implementation(libs.bundles.kmp.common) // Coroutines, Serialization, DateTime
|
||||
implementation(libs.bundles.ktor.client.common) // Ktor Client (Core, Auth, JSON, Logging)
|
||||
implementation(libs.bundles.compose.common) // ViewModel & Lifecycle
|
||||
|
||||
// DI
|
||||
implementation(libs.koin.core)
|
||||
}
|
||||
|
||||
commonTest.dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
implementation(libs.ktor.client.mock)
|
||||
}
|
||||
|
||||
jvmTest.dependencies {
|
||||
implementation(libs.mockk)
|
||||
implementation(projects.platform.platformTesting)
|
||||
implementation(libs.bundles.testing.jvm)
|
||||
}
|
||||
|
||||
jvmMain.dependencies {
|
||||
implementation(libs.ktor.client.cio)
|
||||
}
|
||||
|
||||
jsMain.dependencies {
|
||||
implementation(libs.ktor.client.js)
|
||||
}
|
||||
|
||||
/*
|
||||
val wasmJsMain = getByName("wasmJsMain")
|
||||
wasmJsMain.dependencies {
|
||||
implementation(libs.ktor.client.js) // WASM verwendet JS-Client [cite: 7]
|
||||
|
||||
// Compose für shared UI components für WASM
|
||||
implementation(compose.runtime)
|
||||
implementation(compose.foundation)
|
||||
implementation(compose.material3)
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
// KMP Compile-Optionen
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_25)
|
||||
freeCompilerArgs.addAll(
|
||||
"-opt-in=kotlin.RequiresOptIn",
|
||||
// Suppress beta warning for expect/actual classes as per project decision
|
||||
"-Xexpect-actual-classes"
|
||||
)
|
||||
}
|
||||
}
|
||||
-192
@@ -1,192 +0,0 @@
|
||||
package at.mocode.clients.authfeature
|
||||
|
||||
import at.mocode.shared.core.AppConstants
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.forms.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Data classes for authentication API communication
|
||||
*/
|
||||
@Serializable
|
||||
data class LoginRequest(
|
||||
val username: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoginResponse(
|
||||
val success: Boolean,
|
||||
val token: String? = null,
|
||||
val message: String? = null,
|
||||
val userId: String? = null,
|
||||
val username: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* HTTP client for authentication API calls
|
||||
*/
|
||||
class AuthApiClient(
|
||||
private val httpClient: HttpClient,
|
||||
// Keycloak Basis-URL (z. B. http://localhost:8180)
|
||||
private val keycloakBaseUrl: String = AppConstants.KEYCLOAK_URL,
|
||||
// Realm-Name in Keycloak
|
||||
private val realm: String = AppConstants.KEYCLOAK_REALM,
|
||||
// Client-ID (Public Client empfohlen für Frontend-Flows)
|
||||
private val clientId: String = AppConstants.KEYCLOAK_CLIENT_ID,
|
||||
// Optional: Client-Secret (nur bei vertraulichen Clients erforderlich)
|
||||
private val clientSecret: String? = null
|
||||
) {
|
||||
|
||||
/**
|
||||
* Authenticate user with username and password
|
||||
*/
|
||||
suspend fun login(username: String, password: String): LoginResponse {
|
||||
val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token"
|
||||
return try {
|
||||
val response = httpClient.submitForm(
|
||||
url = tokenEndpoint,
|
||||
formParameters = Parameters.build {
|
||||
append("grant_type", "password")
|
||||
append("client_id", clientId)
|
||||
if (!clientSecret.isNullOrBlank()) {
|
||||
append("client_secret", clientSecret)
|
||||
}
|
||||
append("username", username)
|
||||
append("password", password)
|
||||
}
|
||||
) {
|
||||
// Explicit: URL-encoded Form
|
||||
contentType(ContentType.Application.FormUrlEncoded)
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
val kc = response.body<KeycloakTokenResponse>()
|
||||
LoginResponse(
|
||||
success = true,
|
||||
token = kc.access_token,
|
||||
message = null,
|
||||
userId = null,
|
||||
username = username
|
||||
)
|
||||
} else {
|
||||
LoginResponse(
|
||||
success = false,
|
||||
message = "Login fehlgeschlagen: HTTP ${response.status.value}"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LoginResponse(
|
||||
success = false,
|
||||
message = "Verbindungsfehler: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange an authorization code (PKCE) for tokens
|
||||
*/
|
||||
suspend fun exchangeAuthorizationCode(code: String, codeVerifier: String, redirectUri: String): LoginResponse {
|
||||
val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token"
|
||||
return try {
|
||||
val response = httpClient.submitForm(
|
||||
url = tokenEndpoint,
|
||||
formParameters = Parameters.build {
|
||||
append("grant_type", "authorization_code")
|
||||
append("client_id", clientId)
|
||||
if (!clientSecret.isNullOrBlank()) {
|
||||
append("client_secret", clientSecret)
|
||||
}
|
||||
append("code", code)
|
||||
append("code_verifier", codeVerifier)
|
||||
append("redirect_uri", redirectUri)
|
||||
}
|
||||
) {
|
||||
contentType(ContentType.Application.FormUrlEncoded)
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
val kc = response.body<KeycloakTokenResponse>()
|
||||
LoginResponse(
|
||||
success = true,
|
||||
token = kc.access_token,
|
||||
message = null
|
||||
)
|
||||
} else {
|
||||
LoginResponse(
|
||||
success = false,
|
||||
message = "Code-Exchange fehlgeschlagen: HTTP ${'$'}{response.status.value}"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LoginResponse(
|
||||
success = false,
|
||||
message = "Code-Exchange Fehler: ${'$'}{e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh authentication token
|
||||
*/
|
||||
suspend fun refreshToken(refreshToken: String): LoginResponse {
|
||||
val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token"
|
||||
return try {
|
||||
val response = httpClient.submitForm(
|
||||
url = tokenEndpoint,
|
||||
formParameters = Parameters.build {
|
||||
append("grant_type", "refresh_token")
|
||||
append("client_id", clientId)
|
||||
if (!clientSecret.isNullOrBlank()) {
|
||||
append("client_secret", clientSecret)
|
||||
}
|
||||
append("refresh_token", refreshToken)
|
||||
}
|
||||
) {
|
||||
contentType(ContentType.Application.FormUrlEncoded)
|
||||
}
|
||||
|
||||
if (response.status.isSuccess()) {
|
||||
val kc = response.body<KeycloakTokenResponse>()
|
||||
LoginResponse(
|
||||
success = true,
|
||||
token = kc.access_token,
|
||||
message = null
|
||||
)
|
||||
} else {
|
||||
LoginResponse(
|
||||
success = false,
|
||||
message = "Token refresh fehlgeschlagen: HTTP ${response.status.value}"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LoginResponse(
|
||||
success = false,
|
||||
message = "Token refresh Fehler: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout and invalidate token
|
||||
*/
|
||||
suspend fun logout(token: String): Boolean {
|
||||
// Empfehlung: Frontend-seitig Token lokal verwerfen.
|
||||
// Optional könnten hier Keycloak-Endpoints für Token-Revocation aufgerufen werden.
|
||||
return true
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class KeycloakTokenResponse(
|
||||
val access_token: String,
|
||||
val expires_in: Long? = null,
|
||||
val refresh_expires_in: Long? = null,
|
||||
val refresh_token: String? = null,
|
||||
val token_type: String? = null,
|
||||
val not_before_policy: Long? = null,
|
||||
val session_state: String? = null,
|
||||
val scope: String? = null
|
||||
)
|
||||
}
|
||||
-338
@@ -1,338 +0,0 @@
|
||||
package at.mocode.clients.authfeature
|
||||
|
||||
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 kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
/**
|
||||
* Client-side permission enumeration that mirrors server-side BerechtigungE
|
||||
*/
|
||||
@Serializable
|
||||
enum class Permission {
|
||||
// Person management
|
||||
PERSON_READ,
|
||||
PERSON_CREATE,
|
||||
PERSON_UPDATE,
|
||||
PERSON_DELETE,
|
||||
|
||||
// Club management
|
||||
VEREIN_READ,
|
||||
VEREIN_CREATE,
|
||||
VEREIN_UPDATE,
|
||||
VEREIN_DELETE,
|
||||
|
||||
// Event management
|
||||
VERANSTALTUNG_READ,
|
||||
VERANSTALTUNG_CREATE,
|
||||
VERANSTALTUNG_UPDATE,
|
||||
VERANSTALTUNG_DELETE,
|
||||
|
||||
// Horse management
|
||||
PFERD_READ,
|
||||
PFERD_CREATE,
|
||||
PFERD_UPDATE,
|
||||
PFERD_DELETE
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT token payload for basic validation and permissions extraction
|
||||
*/
|
||||
@Serializable
|
||||
data class JwtPayload(
|
||||
val sub: String? = null, // User ID
|
||||
val username: String? = null, // Username
|
||||
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
|
||||
)
|
||||
|
||||
/**
|
||||
* Authentication state
|
||||
*/
|
||||
data class AuthState(
|
||||
val isAuthenticated: Boolean = false,
|
||||
val token: String? = null,
|
||||
val userId: String? = null,
|
||||
val username: String? = null,
|
||||
val permissions: List<Permission> = emptyList()
|
||||
)
|
||||
|
||||
/**
|
||||
* Secure in-memory JWT token manager
|
||||
*
|
||||
* For web clients, storing tokens in memory is the most secure approach
|
||||
* to prevent XSS attacks. The token is lost when the browser tab is closed
|
||||
* or refreshed, requiring re-authentication.
|
||||
*/
|
||||
class AuthTokenManager {
|
||||
|
||||
private var currentToken: String? = null
|
||||
private var tokenPayload: JwtPayload? = null
|
||||
|
||||
private val _authState = MutableStateFlow(AuthState())
|
||||
val authState: StateFlow<AuthState> = _authState.asStateFlow()
|
||||
|
||||
/**
|
||||
* Store JWT token in memory
|
||||
*/
|
||||
fun setToken(token: String) {
|
||||
currentToken = token
|
||||
tokenPayload = parseJwtPayload(token)
|
||||
|
||||
// Parse permissions from token payload
|
||||
val permissions = tokenPayload?.permissions?.mapNotNull { permissionString ->
|
||||
try {
|
||||
Permission.valueOf(permissionString)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// Ignore unknown permissions
|
||||
null
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
_authState.value = AuthState(
|
||||
isAuthenticated = true,
|
||||
token = token,
|
||||
userId = tokenPayload?.sub,
|
||||
username = tokenPayload?.username,
|
||||
permissions = permissions
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current JWT token
|
||||
*/
|
||||
fun getToken(): String? = currentToken
|
||||
|
||||
/**
|
||||
* Check if we have a valid (non-expired) token
|
||||
*/
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun hasValidToken(): Boolean {
|
||||
val token = currentToken ?: return false
|
||||
val payload = tokenPayload ?: return false
|
||||
|
||||
// Check expiration
|
||||
val expiration = payload.exp ?: return false
|
||||
val currentTime = kotlin.time.Clock.System.now().epochSeconds
|
||||
|
||||
return currentTime < expiration
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear token from memory (logout)
|
||||
*/
|
||||
fun clearToken() {
|
||||
currentToken = null
|
||||
tokenPayload = null
|
||||
|
||||
_authState.value = AuthState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user ID from token
|
||||
*/
|
||||
fun getUserId(): String? = tokenPayload?.sub
|
||||
|
||||
/**
|
||||
* Get username from token
|
||||
*/
|
||||
fun getUsername(): String? = tokenPayload?.username
|
||||
|
||||
/**
|
||||
* Get current user permissions
|
||||
*/
|
||||
fun getPermissions(): List<Permission> = _authState.value.permissions
|
||||
|
||||
/**
|
||||
* Check if user has a specific permission
|
||||
*/
|
||||
fun hasPermission(permission: Permission): Boolean {
|
||||
return _authState.value.permissions.contains(permission)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has any of the specified permissions
|
||||
*/
|
||||
fun hasAnyPermission(vararg permissions: Permission): Boolean {
|
||||
return permissions.any { _authState.value.permissions.contains(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has all of the specified permissions
|
||||
*/
|
||||
fun hasAllPermissions(vararg permissions: Permission): Boolean {
|
||||
return permissions.all { _authState.value.permissions.contains(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can perform read operations
|
||||
*/
|
||||
fun canRead(): Boolean {
|
||||
return hasAnyPermission(
|
||||
Permission.PERSON_READ,
|
||||
Permission.VEREIN_READ,
|
||||
Permission.VERANSTALTUNG_READ,
|
||||
Permission.PFERD_READ
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can perform create operations
|
||||
*/
|
||||
fun canCreate(): Boolean {
|
||||
return hasAnyPermission(
|
||||
Permission.PERSON_CREATE,
|
||||
Permission.VEREIN_CREATE,
|
||||
Permission.VERANSTALTUNG_CREATE,
|
||||
Permission.PFERD_CREATE
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can perform update operations
|
||||
*/
|
||||
fun canUpdate(): Boolean {
|
||||
return hasAnyPermission(
|
||||
Permission.PERSON_UPDATE,
|
||||
Permission.VEREIN_UPDATE,
|
||||
Permission.VERANSTALTUNG_UPDATE,
|
||||
Permission.PFERD_UPDATE
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can perform delete operations (admin-level)
|
||||
*/
|
||||
fun canDelete(): Boolean {
|
||||
return hasAnyPermission(
|
||||
Permission.PERSON_DELETE,
|
||||
Permission.VEREIN_DELETE,
|
||||
Permission.VERANSTALTUNG_DELETE,
|
||||
Permission.PFERD_DELETE
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is admin (has delete permissions)
|
||||
*/
|
||||
fun isAdmin(): Boolean = canDelete()
|
||||
|
||||
/**
|
||||
* Check if token expires within specified minutes
|
||||
*/
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun isTokenExpiringSoon(minutesThreshold: Int = 5): Boolean {
|
||||
val payload = tokenPayload ?: return false
|
||||
val expiration = payload.exp ?: return false
|
||||
val currentTime = kotlin.time.Clock.System.now().epochSeconds
|
||||
val thresholdTime = currentTime + (minutesThreshold * 60)
|
||||
|
||||
return expiration <= thresholdTime
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JWT payload for basic validation and user info extraction
|
||||
* Note: This is for client-side info extraction only, not security validation
|
||||
*/
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
private fun parseJwtPayload(token: String): JwtPayload? {
|
||||
return try {
|
||||
val parts = token.split(".")
|
||||
if (parts.size != 3) return null
|
||||
|
||||
// Decode the payload (second part)
|
||||
val payloadJson = Base64.decode(parts[1]).decodeToString()
|
||||
|
||||
// First try to parse with standard approach
|
||||
val basicPayload = try {
|
||||
Json.decodeFromString<JwtPayload>(payloadJson)
|
||||
} catch (e: Exception) {
|
||||
// If that fails, extract manually
|
||||
null
|
||||
}
|
||||
|
||||
// If basic parsing succeeded and has permissions, return it
|
||||
if (basicPayload != null && basicPayload.permissions != null) {
|
||||
return basicPayload
|
||||
}
|
||||
|
||||
// Otherwise, extract permissions manually from JSON string
|
||||
val permissions = extractPermissionsFromJson(payloadJson)
|
||||
|
||||
// Return payload with manually extracted permissions
|
||||
JwtPayload(
|
||||
sub = basicPayload?.sub,
|
||||
username = basicPayload?.username,
|
||||
exp = basicPayload?.exp,
|
||||
iat = basicPayload?.iat,
|
||||
iss = basicPayload?.iss,
|
||||
permissions = permissions
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Failed to parse - token might be invalid format
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract permissions array from JSON string using simple string parsing
|
||||
*/
|
||||
private fun extractPermissionsFromJson(jsonString: String): List<String>? {
|
||||
return try {
|
||||
// Simple regex to find permissions array
|
||||
val permissionsRegex = """"permissions":\s*\[(.*?)\]""".toRegex()
|
||||
val match = permissionsRegex.find(jsonString)
|
||||
|
||||
match?.let {
|
||||
val permissionsContent = it.groupValues[1]
|
||||
if (permissionsContent.isBlank()) return emptyList()
|
||||
|
||||
// Extract individual permission strings
|
||||
val permissions = permissionsContent
|
||||
.split(",")
|
||||
.mapNotNull { permission ->
|
||||
permission.trim()
|
||||
.removePrefix("\"")
|
||||
.removeSuffix("\"")
|
||||
.takeIf { it.isNotBlank() }
|
||||
}
|
||||
permissions
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token with Bearer prefix for HTTP headers
|
||||
*/
|
||||
fun getBearerToken(): String? {
|
||||
val token = getToken() ?: return null
|
||||
return "Bearer $token"
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh token if needed based on expiry
|
||||
*/
|
||||
suspend fun refreshTokenIfNeeded(authApiClient: AuthApiClient): Boolean {
|
||||
if (!isTokenExpiringSoon()) return true
|
||||
|
||||
val currentToken = getToken() ?: return false
|
||||
|
||||
val refreshResponse = authApiClient.refreshToken(currentToken)
|
||||
if (refreshResponse.success && refreshResponse.token != null) {
|
||||
setToken(refreshResponse.token)
|
||||
return true
|
||||
}
|
||||
|
||||
// Refresh failed, clear token
|
||||
clearToken()
|
||||
return false
|
||||
}
|
||||
}
|
||||
-134
@@ -1,134 +0,0 @@
|
||||
package at.mocode.clients.authfeature
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
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.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
viewModel: LoginViewModel,
|
||||
onLoginSuccess: () -> Unit = {}
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val passwordFocusRequester = remember { FocusRequester() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
// Title
|
||||
Text(
|
||||
text = "Anmelden",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(bottom = 32.dp)
|
||||
)
|
||||
|
||||
// Username field
|
||||
OutlinedTextField(
|
||||
value = uiState.username,
|
||||
onValueChange = viewModel::updateUsername,
|
||||
label = { Text("Benutzername") },
|
||||
enabled = !uiState.isLoading,
|
||||
isError = uiState.usernameError != null,
|
||||
supportingText = uiState.usernameError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { passwordFocusRequester.requestFocus() }
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
// Password field
|
||||
OutlinedTextField(
|
||||
value = uiState.password,
|
||||
onValueChange = viewModel::updatePassword,
|
||||
label = { Text("Passwort") },
|
||||
enabled = !uiState.isLoading,
|
||||
isError = uiState.passwordError != null,
|
||||
supportingText = uiState.passwordError?.let { { Text(it) } },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
if (uiState.canLogin) {
|
||||
viewModel.login()
|
||||
}
|
||||
}
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(passwordFocusRequester)
|
||||
.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 = { 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle login success
|
||||
LaunchedEffect(uiState.isAuthenticated) {
|
||||
if (uiState.isAuthenticated) {
|
||||
onLoginSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
-128
@@ -1,128 +0,0 @@
|
||||
package at.mocode.clients.authfeature
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.HttpClient
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* UI state for the login screen
|
||||
*/
|
||||
data class LoginUiState(
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val isAuthenticated: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val usernameError: String? = null,
|
||||
val passwordError: String? = null
|
||||
) {
|
||||
val canLogin: Boolean
|
||||
get() = username.isNotBlank() && password.isNotBlank() && !isLoading
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewModel for handling login authentication logic
|
||||
*/
|
||||
class LoginViewModel(
|
||||
private val authTokenManager: AuthTokenManager,
|
||||
private val authApiClient: AuthApiClient,
|
||||
private val apiClient: HttpClient
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(LoginUiState())
|
||||
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun updateUsername(username: String) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
username = username,
|
||||
usernameError = null,
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
|
||||
fun updatePassword(password: String) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
password = password,
|
||||
passwordError = null,
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
|
||||
fun login() {
|
||||
val currentState = _uiState.value
|
||||
|
||||
// Validate input
|
||||
if (currentState.username.isBlank()) {
|
||||
_uiState.value = currentState.copy(usernameError = "Benutzername ist erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
if (currentState.password.isBlank()) {
|
||||
_uiState.value = currentState.copy(passwordError = "Passwort ist erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
// Start the login process
|
||||
_uiState.value = currentState.copy(
|
||||
isLoading = true,
|
||||
errorMessage = null,
|
||||
usernameError = null,
|
||||
passwordError = null
|
||||
)
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val loginResponse = authApiClient.login(
|
||||
username = currentState.username,
|
||||
password = currentState.password
|
||||
)
|
||||
|
||||
if (loginResponse.success && loginResponse.token != null) {
|
||||
// Store the JWT token
|
||||
authTokenManager.setToken(loginResponse.token)
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
isAuthenticated = true,
|
||||
errorMessage = null
|
||||
)
|
||||
|
||||
// Fire-and-forget: Trigger Backend Sync so the user exists in Members
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// Fire-and-forget sync call; Bearer token added by Ktor Auth plugin
|
||||
apiClient.post("/api/members/sync")
|
||||
} catch (_: Exception) {
|
||||
// Non-fatal: Wir zeigen Sync-Fehler im Login nicht an
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = loginResponse.message ?: "Anmeldung fehlgeschlagen"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Verbindungsfehler: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
authTokenManager.clearToken()
|
||||
_uiState.value = LoginUiState()
|
||||
}
|
||||
|
||||
fun checkAuthenticationStatus() {
|
||||
val isAuthenticated = authTokenManager.hasValidToken()
|
||||
_uiState.value = _uiState.value.copy(isAuthenticated = isAuthenticated)
|
||||
}
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
package at.mocode.clients.authfeature.di
|
||||
|
||||
import at.mocode.clients.authfeature.AuthApiClient
|
||||
import at.mocode.clients.authfeature.AuthTokenManager
|
||||
import at.mocode.clients.authfeature.LoginViewModel
|
||||
import at.mocode.frontend.core.network.TokenProvider
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
|
||||
/**
|
||||
* Koin module for auth-feature: provides AuthTokenManager and binds it as TokenProvider for apiClient.
|
||||
*/
|
||||
val authFeatureModule = module {
|
||||
// Single in-memory token manager
|
||||
single { AuthTokenManager() }
|
||||
|
||||
// AuthApiClient with injected apiClient
|
||||
single { AuthApiClient(get(named("apiClient"))) }
|
||||
|
||||
// LoginViewModel
|
||||
factory { LoginViewModel(get(), get(), get(named("apiClient"))) }
|
||||
|
||||
// Bridge to core network TokenProvider without adding a hard dependency there
|
||||
single<TokenProvider> {
|
||||
object : TokenProvider {
|
||||
override fun getAccessToken(): String? {
|
||||
val token = get<AuthTokenManager>().getToken()
|
||||
return token
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
package at.mocode.clients.authfeature.oauth
|
||||
|
||||
data class CallbackParams(val code: String, val state: String?)
|
||||
|
||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||
expect object AuthCallbackParams {
|
||||
/**
|
||||
* Parse OAuth callback parameters from the current environment.
|
||||
* - JS (web): reads window.location.search for `code` and `state` and removes them from the URL.
|
||||
* - JVM (desktop): returns null.
|
||||
*/
|
||||
fun parse(): CallbackParams?
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
package at.mocode.clients.authfeature.oauth
|
||||
|
||||
import at.mocode.shared.core.AppConstants
|
||||
|
||||
data class PkceState(
|
||||
val state: String,
|
||||
val codeVerifier: String,
|
||||
val codeChallenge: String,
|
||||
val method: String = "S256"
|
||||
)
|
||||
|
||||
object OAuthParams {
|
||||
const val RESPONSE_TYPE = "code"
|
||||
const val SCOPE = "openid"
|
||||
}
|
||||
|
||||
/**
|
||||
* expect/actual service to support PKCE across JS and JVM.
|
||||
* For the desktop (JVM) target we currently do not start a browser flow,
|
||||
* but we provide hashing to keep API parity.
|
||||
*/
|
||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||
expect object OAuthPkceService {
|
||||
/** Starts a PKCE auth attempt and stores transient state in memory. */
|
||||
suspend fun startAuth(): PkceState
|
||||
|
||||
/** Returns currently active state if any (not persisted). */
|
||||
fun current(): PkceState?
|
||||
|
||||
/** Clears transient state (after success/failure). */
|
||||
fun clear()
|
||||
|
||||
/** Builds the authorize URL for the current state. */
|
||||
fun buildAuthorizeUrl(state: PkceState, redirectUri: String = AppConstants.webRedirectUri()): String
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
package at.mocode.clients.authfeature.oauth
|
||||
|
||||
import kotlinx.browser.window
|
||||
|
||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||
actual object AuthCallbackParams {
|
||||
actual fun parse(): CallbackParams? {
|
||||
val search = window.location.search
|
||||
if (search.isBlank()) return null
|
||||
val params = js("new URLSearchParams(arguments[0])").unsafeCast<(String) -> dynamic>()(search)
|
||||
val code = params.get("code") as String?
|
||||
val state = params.get("state") as String?
|
||||
return if (!code.isNullOrBlank()) {
|
||||
// Clean up query params to avoid re-processing on recomposition
|
||||
val url = window.location.origin + window.location.pathname
|
||||
window.history.replaceState(null, "", url)
|
||||
CallbackParams(code, state)
|
||||
} else null
|
||||
}
|
||||
}
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
package at.mocode.clients.authfeature.oauth
|
||||
|
||||
import at.mocode.shared.core.AppConstants
|
||||
import kotlinx.browser.window
|
||||
import kotlinx.coroutines.await
|
||||
import org.khronos.webgl.ArrayBuffer
|
||||
import org.khronos.webgl.Uint8Array
|
||||
import kotlin.js.Promise
|
||||
import kotlin.random.Random
|
||||
|
||||
private var currentPkce: PkceState? = null
|
||||
|
||||
private fun base64UrlFromBytes(bytes: ByteArray): String {
|
||||
// Build binary string from bytes
|
||||
val sb = StringBuilder(bytes.size)
|
||||
for (b in bytes) sb.append(b.toInt().toChar())
|
||||
val b64 = window.btoa(sb.toString())
|
||||
return b64.replace("+", "-").replace("/", "_").trimEnd('=')
|
||||
}
|
||||
|
||||
private fun base64UrlFromArrayBuffer(buf: ArrayBuffer): String {
|
||||
val arr = Uint8Array(buf)
|
||||
var binary = ""
|
||||
val len = arr.length
|
||||
for (i in 0 until len) {
|
||||
val v = (arr.asDynamic()[i] as Number).toInt()
|
||||
binary += fromCharCode(v)
|
||||
}
|
||||
val b64 = window.btoa(binary)
|
||||
return b64.replace("+", "-").replace("/", "_").trimEnd('=')
|
||||
}
|
||||
|
||||
private fun randomUrlSafe(length: Int): String {
|
||||
val bytes = Random.nextBytes(length)
|
||||
// Use base64url for entropy; ensure URL-safe by replacing padding removed already
|
||||
return base64UrlFromBytes(bytes)
|
||||
}
|
||||
|
||||
private fun sha256(input: String): Promise<ArrayBuffer> {
|
||||
val enc: dynamic = js("new TextEncoder()")
|
||||
val data = enc.encode(input)
|
||||
val subtle: dynamic = window.asDynamic().crypto.subtle
|
||||
return subtle.digest("SHA-256", data) as Promise<ArrayBuffer>
|
||||
}
|
||||
|
||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||
actual object OAuthPkceService {
|
||||
actual suspend fun startAuth(): PkceState {
|
||||
val codeVerifier = randomUrlSafe(64)
|
||||
val challengeBuf = sha256(codeVerifier).await()
|
||||
val codeChallenge = base64UrlFromArrayBuffer(challengeBuf)
|
||||
val state = randomUrlSafe(16)
|
||||
val pkce = PkceState(state, codeVerifier, codeChallenge)
|
||||
currentPkce = pkce
|
||||
return pkce
|
||||
}
|
||||
|
||||
actual fun current(): PkceState? = currentPkce
|
||||
|
||||
actual fun clear() {
|
||||
currentPkce = null
|
||||
}
|
||||
|
||||
actual fun buildAuthorizeUrl(state: PkceState, redirectUri: String): String {
|
||||
val params = listOf(
|
||||
"response_type" to OAuthParams.RESPONSE_TYPE,
|
||||
"client_id" to AppConstants.KEYCLOAK_CLIENT_ID,
|
||||
"redirect_uri" to redirectUri,
|
||||
"scope" to OAuthParams.SCOPE,
|
||||
"state" to state.state,
|
||||
"code_challenge" to state.codeChallenge,
|
||||
"code_challenge_method" to state.method
|
||||
).joinToString("&") { (k, v) -> "$k=" + encodeURIComponent(v) }
|
||||
return AppConstants.authorizeEndpoint() + "?" + params
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UnsafeCastFromDynamic")
|
||||
private fun encodeURIComponent(value: String): String = js("encodeURIComponent")(value)
|
||||
|
||||
@Suppress("UnsafeCastFromDynamic")
|
||||
private fun fromCharCode(code: Int): String = js("String.fromCharCode")(code)
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
package at.mocode.clients.authfeature.oauth
|
||||
|
||||
actual object AuthCallbackParams {
|
||||
actual fun parse(): CallbackParams? = null
|
||||
}
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
package at.mocode.clients.authfeature.oauth
|
||||
|
||||
import at.mocode.shared.core.AppConstants
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.Base64
|
||||
|
||||
private var currentPkceJvm: PkceState? = null
|
||||
|
||||
private fun base64UrlNoPad(bytes: ByteArray): String =
|
||||
Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
|
||||
|
||||
private fun randomUrlSafe(length: Int): String {
|
||||
// Generate bytes and Base64 URL encode (will be > length due to encoding)
|
||||
val rnd = SecureRandom()
|
||||
val bytes = ByteArray(length)
|
||||
rnd.nextBytes(bytes)
|
||||
return base64UrlNoPad(bytes)
|
||||
}
|
||||
|
||||
private fun sha256Base64Url(input: String): String {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
val digest = md.digest(input.toByteArray(Charsets.UTF_8))
|
||||
return base64UrlNoPad(digest)
|
||||
}
|
||||
|
||||
actual object OAuthPkceService {
|
||||
actual suspend fun startAuth(): PkceState {
|
||||
val codeVerifier = randomUrlSafe(64)
|
||||
val codeChallenge = sha256Base64Url(codeVerifier)
|
||||
val state = randomUrlSafe(16)
|
||||
val pkce = PkceState(state, codeVerifier, codeChallenge)
|
||||
currentPkceJvm = pkce
|
||||
return pkce
|
||||
}
|
||||
|
||||
actual fun current(): PkceState? = currentPkceJvm
|
||||
|
||||
actual fun clear() {
|
||||
currentPkceJvm = null
|
||||
}
|
||||
|
||||
actual fun buildAuthorizeUrl(state: PkceState, redirectUri: String): String {
|
||||
val params = listOf(
|
||||
"response_type" to OAuthParams.RESPONSE_TYPE,
|
||||
"client_id" to AppConstants.KEYCLOAK_CLIENT_ID,
|
||||
"redirect_uri" to redirectUri,
|
||||
"scope" to OAuthParams.SCOPE,
|
||||
"state" to state.state,
|
||||
"code_challenge" to state.codeChallenge,
|
||||
"code_challenge_method" to state.method
|
||||
).joinToString("&") { (k, v) -> "$k=" + java.net.URLEncoder.encode(v, Charsets.UTF_8) }
|
||||
return AppConstants.authorizeEndpoint() + "?" + params
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
/**
|
||||
* Dieses Modul kapselt die gesamte UI und Logik für das Authentication-Feature.
|
||||
* Es kennt seine eigenen technischen Abhängigkeiten (Ktor, Coroutines)
|
||||
* und den UI-Baukasten (common-ui), aber es kennt keine anderen Features.
|
||||
*/
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
alias(libs.plugins.kotlinSerialization)
|
||||
}
|
||||
|
||||
group = "at.mocode.clients"
|
||||
version = "1.0.0"
|
||||
|
||||
kotlin {
|
||||
// Toolchain is now handled centrally in the root build.gradle.kts
|
||||
val enableWasm = providers.gradleProperty("enableWasm").orNull == "true"
|
||||
|
||||
jvm()
|
||||
|
||||
js {
|
||||
browser {
|
||||
testTask {
|
||||
enabled = false
|
||||
}
|
||||
}
|
||||
binaries.executable()
|
||||
}
|
||||
|
||||
// WASM, nur wenn explizit aktiviert
|
||||
if (enableWasm) {
|
||||
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
||||
wasmJs {
|
||||
browser()
|
||||
binaries.executable()
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
// UI Kit (Design System)
|
||||
implementation(project(":frontend:core:design-system"))
|
||||
|
||||
// Shared Konfig & Utilities (AppConfig + BuildConfig)
|
||||
implementation(project(":frontend:shared"))
|
||||
|
||||
// Compose dependencies
|
||||
implementation(compose.runtime)
|
||||
implementation(compose.foundation)
|
||||
implementation(compose.material3)
|
||||
implementation(compose.ui)
|
||||
implementation(compose.components.resources)
|
||||
implementation(compose.materialIconsExtended)
|
||||
|
||||
// Bundles (Cleaned up dependencies)
|
||||
implementation(libs.bundles.kmp.common) // Coroutines, Serialization, DateTime
|
||||
implementation(libs.bundles.ktor.client.common) // Ktor Client (Core, Auth, JSON, Logging)
|
||||
implementation(libs.bundles.compose.common) // ViewModel & Lifecycle
|
||||
}
|
||||
|
||||
commonTest.dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
implementation(libs.ktor.client.mock)
|
||||
}
|
||||
|
||||
jvmTest.dependencies {
|
||||
implementation(libs.mockk)
|
||||
implementation(projects.platform.platformTesting)
|
||||
implementation(libs.bundles.testing.jvm)
|
||||
}
|
||||
|
||||
jvmMain.dependencies {
|
||||
implementation(libs.ktor.client.cio)
|
||||
}
|
||||
|
||||
jsMain.dependencies {
|
||||
implementation(libs.ktor.client.js)
|
||||
}
|
||||
|
||||
// WASM SourceSet, nur wenn aktiviert
|
||||
if (enableWasm) {
|
||||
val wasmJsMain = getByName("wasmJsMain")
|
||||
wasmJsMain.dependencies {
|
||||
implementation(libs.ktor.client.js) // WASM verwendet JS-Client [cite: 7]
|
||||
|
||||
// Compose für shared UI components für WASM
|
||||
implementation(compose.runtime)
|
||||
implementation(compose.foundation)
|
||||
implementation(compose.material3)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// KMP Compile-Optionen
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_25)
|
||||
freeCompilerArgs.addAll(
|
||||
"-opt-in=kotlin.RequiresOptIn",
|
||||
// Suppress beta warning for expect/actual classes as per project decision
|
||||
"-Xexpect-actual-classes"
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -20,6 +20,7 @@ class PingSyncServiceImpl(
|
||||
) : PingSyncService {
|
||||
|
||||
override suspend fun syncPings() {
|
||||
syncManager.performSync(repository, "/api/pings/sync")
|
||||
// Corrected endpoint: /api/ping/sync (singular)
|
||||
syncManager.performSync(repository, "/api/ping/sync")
|
||||
}
|
||||
}
|
||||
|
||||
+101
-91
@@ -8,121 +8,131 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PingScreen(viewModel: PingViewModel) {
|
||||
fun PingScreen(
|
||||
viewModel: PingViewModel,
|
||||
onBack: () -> Unit = {}
|
||||
) {
|
||||
val uiState = viewModel.uiState
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
.verticalScroll(scrollState),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Ping Service",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
if (uiState.isLoading || uiState.isSyncing) {
|
||||
CircularProgressIndicator()
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Ping Service") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp)
|
||||
.verticalScroll(scrollState),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
if (uiState.isLoading || uiState.isSyncing) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
if (uiState.errorMessage != null) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Error",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Text(text = uiState.errorMessage)
|
||||
Button(onClick = { viewModel.clearError() }) {
|
||||
Text("Clear")
|
||||
if (uiState.errorMessage != null) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Error",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Text(text = uiState.errorMessage)
|
||||
Button(onClick = { viewModel.clearError() }) {
|
||||
Text("Clear")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.lastSyncResult != null) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Sync Status",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(text = uiState.lastSyncResult)
|
||||
if (uiState.lastSyncResult != null) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Sync Status",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(text = uiState.lastSyncResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { viewModel.performSimplePing() }) {
|
||||
Text("Simple Ping")
|
||||
}
|
||||
Button(onClick = { viewModel.performEnhancedPing() }) {
|
||||
Text("Enhanced Ping")
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { viewModel.performHealthCheck() }) {
|
||||
Text("Health Check")
|
||||
}
|
||||
Button(onClick = { viewModel.performSecurePing() }) {
|
||||
Text("Secure Ping")
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { viewModel.triggerSync() }) {
|
||||
Text("Sync Now")
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.simplePingResponse != null) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("Simple / Secure Ping Response:", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Status: ${uiState.simplePingResponse.status}")
|
||||
Text("Service: ${uiState.simplePingResponse.service}")
|
||||
Text("Timestamp: ${uiState.simplePingResponse.timestamp}")
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { viewModel.performSimplePing() }) {
|
||||
Text("Simple Ping")
|
||||
}
|
||||
Button(onClick = { viewModel.performEnhancedPing() }) {
|
||||
Text("Enhanced Ping")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.enhancedPingResponse != null) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("Enhanced Ping Response:", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Status: ${uiState.enhancedPingResponse.status}")
|
||||
Text("Timestamp: ${uiState.enhancedPingResponse.timestamp}")
|
||||
Text("Circuit Breaker: ${uiState.enhancedPingResponse.circuitBreakerState}")
|
||||
Text("Response Time: ${uiState.enhancedPingResponse.responseTime}ms")
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { viewModel.performHealthCheck() }) {
|
||||
Text("Health Check")
|
||||
}
|
||||
Button(onClick = { viewModel.performSecurePing() }) {
|
||||
Text("Secure Ping")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.healthResponse != null) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("Health Response:", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Status: ${uiState.healthResponse.status}")
|
||||
Text("Healthy: ${uiState.healthResponse.healthy}")
|
||||
Text("Service: ${uiState.healthResponse.service}")
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { viewModel.triggerSync() }) {
|
||||
Text("Sync Now")
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.simplePingResponse != null) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("Simple / Secure Ping Response:", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Status: ${uiState.simplePingResponse.status}")
|
||||
Text("Service: ${uiState.simplePingResponse.service}")
|
||||
Text("Timestamp: ${uiState.simplePingResponse.timestamp}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.enhancedPingResponse != null) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("Enhanced Ping Response:", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Status: ${uiState.enhancedPingResponse.status}")
|
||||
Text("Timestamp: ${uiState.enhancedPingResponse.timestamp}")
|
||||
Text("Circuit Breaker: ${uiState.enhancedPingResponse.circuitBreakerState}")
|
||||
Text("Response Time: ${uiState.enhancedPingResponse.responseTime}ms")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.healthResponse != null) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("Health Response:", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Status: ${uiState.healthResponse.status}")
|
||||
Text("Healthy: ${uiState.healthResponse.healthy}")
|
||||
Text("Service: ${uiState.healthResponse.service}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user