Merge pull request #18
* MP-19 Refactoring: Einführung der "Registry" & "Masterdata" Trennung … * MP-19 Refactoring: Frontend Tabula Rasa * MP-19 Refactoring: Frontend Tabula Rasa * refactoring: * MP-20 fix(docker/clients): include `:domains` module in web/desktop b… * MP-20 fix(web-app build): resolve JS compile error and add dev/prod b… * MP-20 fix(web-app): remove vendor.js reference and harden JS bootstra… * MP-20 fixing: clients * MP-20 fixing: clients
This commit is contained in:
@@ -50,3 +50,8 @@ GATEWAY_SERVER_PORT=8081
|
|||||||
# --- MICROSERVICES ---
|
# --- MICROSERVICES ---
|
||||||
PING_SERVICE_PORT=8082:8082
|
PING_SERVICE_PORT=8082:8082
|
||||||
PING_DEBUG_PORT=5006:5006
|
PING_DEBUG_PORT=5006:5006
|
||||||
|
|
||||||
|
# --- CLIENT APPLICATIONS ---
|
||||||
|
WEB_APP_PORT=4000:4000
|
||||||
|
DESKTOP_APP_VNC_PORT=5901:5901
|
||||||
|
DESKTOP_APP_NOVNC_PORT=6080:6080
|
||||||
|
|||||||
@@ -51,3 +51,10 @@ CONSUL_PORT=8500:8500
|
|||||||
GATEWAY_PORT=8081
|
GATEWAY_PORT=8081
|
||||||
# Debug Port für IntelliJ (Remote JVM Debug)
|
# Debug Port für IntelliJ (Remote JVM Debug)
|
||||||
GATEWAY_DEBUG_PORT=5005
|
GATEWAY_DEBUG_PORT=5005
|
||||||
|
|
||||||
|
# --- CLIENT APPLICATIONS ---
|
||||||
|
# Web-App (Kotlin/JS, kein WASM)
|
||||||
|
WEB_APP_PORT=4000:4000
|
||||||
|
# Desktop-App (VNC/noVNC)
|
||||||
|
DESKTOP_APP_VNC_PORT=5901:5901
|
||||||
|
DESKTOP_APP_NOVNC_PORT=6080:6080
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ kotlin {
|
|||||||
|
|
||||||
webpackTask {
|
webpackTask {
|
||||||
mainOutputFileName = "web-app.js"
|
mainOutputFileName = "web-app.js"
|
||||||
output.libraryTarget = "commonjs2"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Development Server konfigurieren
|
// Development Server konfigurieren
|
||||||
@@ -67,7 +66,10 @@ kotlin {
|
|||||||
// WASM, nur wenn explizit aktiviert
|
// WASM, nur wenn explizit aktiviert
|
||||||
if (enableWasm) {
|
if (enableWasm) {
|
||||||
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
||||||
wasmJs { browser() }
|
wasmJs {
|
||||||
|
browser()
|
||||||
|
binaries.executable()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
@@ -76,8 +78,8 @@ kotlin {
|
|||||||
implementation(project(":clients:shared"))
|
implementation(project(":clients:shared"))
|
||||||
implementation(project(":clients:shared:common-ui"))
|
implementation(project(":clients:shared:common-ui"))
|
||||||
implementation(project(":clients:shared:navigation"))
|
implementation(project(":clients:shared:navigation"))
|
||||||
|
implementation(project(":clients:auth-feature"))
|
||||||
implementation(project(":clients:ping-feature"))
|
implementation(project(":clients:ping-feature"))
|
||||||
implementation(project(":clients:members-feature"))
|
|
||||||
|
|
||||||
// Compose Multiplatform
|
// Compose Multiplatform
|
||||||
implementation(compose.runtime)
|
implementation(compose.runtime)
|
||||||
@@ -129,7 +131,9 @@ tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
|
|||||||
jvmTarget.set(JvmTarget.JVM_21)
|
jvmTarget.set(JvmTarget.JVM_21)
|
||||||
freeCompilerArgs.addAll(
|
freeCompilerArgs.addAll(
|
||||||
"-opt-in=kotlin.RequiresOptIn",
|
"-opt-in=kotlin.RequiresOptIn",
|
||||||
"-Xskip-metadata-version-check" // Für bleeding-edge Versionen
|
"-Xskip-metadata-version-check", // Für bleeding-edge Versionen
|
||||||
|
// Suppress beta warning for expect/actual declarations used in this module
|
||||||
|
"-Xexpect-actual-classes"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
-3833
File diff suppressed because it is too large
Load Diff
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/jest": "^29.2.5",
|
|
||||||
"jest": "^29.3.1"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"test": "jest"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"webpack-bundle-analyzer": "^4.10.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,10 +2,23 @@ import androidx.compose.material3.*
|
|||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import at.mocode.clients.membersfeature.ProfileScreen
|
import androidx.compose.runtime.collectAsState
|
||||||
import at.mocode.clients.membersfeature.ProfileViewModel
|
|
||||||
import at.mocode.clients.shared.navigation.AppScreen
|
import at.mocode.clients.shared.navigation.AppScreen
|
||||||
|
import at.mocode.clients.authfeature.AuthenticatedHttpClient
|
||||||
|
import at.mocode.clients.authfeature.AuthTokenManager
|
||||||
|
import at.mocode.clients.pingfeature.PingScreen
|
||||||
|
import at.mocode.clients.pingfeature.PingViewModel
|
||||||
|
import at.mocode.clients.shared.core.AppConstants
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import at.mocode.clients.authfeature.AuthApiClient
|
||||||
|
import at.mocode.clients.authfeature.oauth.OAuthPkceService
|
||||||
|
import at.mocode.clients.authfeature.oauth.AuthCallbackParams
|
||||||
|
import at.mocode.clients.authfeature.oauth.CallbackParams
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainApp() {
|
fun MainApp() {
|
||||||
@@ -16,93 +29,231 @@ fun MainApp() {
|
|||||||
) {
|
) {
|
||||||
var currentScreen by remember { mutableStateOf<AppScreen>(AppScreen.Home) }
|
var currentScreen by remember { mutableStateOf<AppScreen>(AppScreen.Home) }
|
||||||
|
|
||||||
|
val authTokenManager = remember { AuthenticatedHttpClient.getAuthTokenManager() }
|
||||||
|
val pingViewModel = remember { PingViewModel() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Handle PKCE callback on an app load (web)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
val callback: CallbackParams? = AuthCallbackParams.parse()
|
||||||
|
if (callback != null) {
|
||||||
|
val code = callback.code
|
||||||
|
val state = callback.state
|
||||||
|
val pkce = OAuthPkceService.current()
|
||||||
|
if (pkce != null && pkce.state == state) {
|
||||||
|
val api = AuthApiClient()
|
||||||
|
val res = api.exchangeAuthorizationCode(code, pkce.codeVerifier, AppConstants.webRedirectUri())
|
||||||
|
val token = res.token
|
||||||
|
if (res.success && token != null) {
|
||||||
|
authTokenManager.setToken(token)
|
||||||
|
OAuthPkceService.clear()
|
||||||
|
currentScreen = AppScreen.Profile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
when (currentScreen) {
|
when (currentScreen) {
|
||||||
is AppScreen.Home -> DevelopmentScreen(onOpenProfile = { currentScreen = AppScreen.Profile })
|
is AppScreen.Home -> WelcomeScreen(
|
||||||
is AppScreen.Login -> DevelopmentScreen(onOpenProfile = { currentScreen = AppScreen.Profile })
|
authTokenManager = authTokenManager,
|
||||||
is AppScreen.Ping -> DevelopmentScreen(onOpenProfile = { currentScreen = AppScreen.Profile })
|
onOpenPing = { AppScreen.Ping },
|
||||||
is AppScreen.Profile -> ProfileScreen(viewModel = remember { ProfileViewModel() })
|
onOpenLogin = {
|
||||||
|
// Fallback to the local LoginScreen (Password Grant) if PKCE cannot be started
|
||||||
|
currentScreen = AppScreen.Login
|
||||||
|
},
|
||||||
|
onOpenProfile = { currentScreen = AppScreen.Profile }
|
||||||
|
)
|
||||||
|
|
||||||
|
is AppScreen.Login -> LoginScreen(
|
||||||
|
authTokenManager = authTokenManager,
|
||||||
|
onLoginSuccess = { currentScreen = AppScreen.Profile }
|
||||||
|
)
|
||||||
|
|
||||||
|
is AppScreen.Ping -> PingScreen(viewModel = pingViewModel)
|
||||||
|
is AppScreen.Profile -> AuthStatusScreen(
|
||||||
|
authTokenManager = authTokenManager,
|
||||||
|
onBackToHome = { currentScreen = AppScreen.Home }
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DevelopmentScreen(onOpenProfile: () -> Unit) {
|
private fun WelcomeScreen(
|
||||||
|
authTokenManager: AuthTokenManager,
|
||||||
|
onOpenPing: () -> Unit,
|
||||||
|
onOpenLogin: () -> Unit,
|
||||||
|
onOpenProfile: () -> Unit
|
||||||
|
) {
|
||||||
|
val authState by authTokenManager.authState.collectAsState()
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(16.dp),
|
.padding(24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
"🚀 Meldestelle Development Mode",
|
text = "Willkommen zur Meldestelle",
|
||||||
style = MaterialTheme.typography.headlineMedium
|
style = MaterialTheme.typography.headlineMedium
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Auth info
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
Text(
|
if (authState.isAuthenticated) {
|
||||||
"🌐 Backend Connectivity",
|
Text("Du bist als ${authState.username ?: authState.userId ?: "unbekannt"} angemeldet.")
|
||||||
style = MaterialTheme.typography.titleMedium
|
Spacer(Modifier.height(8.dp))
|
||||||
)
|
Button(onClick = onOpenProfile) { Text("Profil anzeigen") }
|
||||||
|
} else {
|
||||||
|
Text("Du bist nicht angemeldet.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var testStatus by remember { mutableStateOf("Not tested") }
|
// Actions
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Button(onClick = onOpenPing, modifier = Modifier.weight(1f)) { Text("Ping-Service") }
|
||||||
|
if (!authState.isAuthenticated) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
// Try PKCE login (Authorization Code Flow w/ PKCE)
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val pkce = OAuthPkceService.startAuth()
|
||||||
|
val url = OAuthPkceService.buildAuthorizeUrl(pkce, AppConstants.webRedirectUri())
|
||||||
|
uriHandler.openUri(url)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// Fallback: open the local Login screen (Password Grant)
|
||||||
|
onOpenLogin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) { Text("Login") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Row(
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
OutlinedButton(
|
||||||
|
onClick = { uriHandler.openUri(AppConstants.registerUrl()) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) { Text("Registrieren (Keycloak)") }
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { uriHandler.openUri(AppConstants.loginUrl()) },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) { Text("Keycloak Login-Seite") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop Download Link
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { uriHandler.openUri(AppConstants.desktopDownloadUrl()) },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) { Text("Desktop-App herunterladen") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AuthStatusScreen(
|
||||||
|
authTokenManager: AuthTokenManager,
|
||||||
|
onBackToHome: () -> Unit
|
||||||
) {
|
) {
|
||||||
Button(onClick = { testStatus = "Testing Gateway..." }) {
|
val authState by authTokenManager.authState.collectAsState()
|
||||||
Text("Test Gateway")
|
Column(
|
||||||
}
|
modifier = Modifier
|
||||||
Button(onClick = { testStatus = "Testing Ping Service..." }) {
|
.fillMaxSize()
|
||||||
Text("Test Ping Service")
|
.padding(24.dp),
|
||||||
}
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
Button(onClick = onOpenProfile) {
|
|
||||||
Text("Open Profile")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text("Status: $testStatus")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
|
||||||
Text(
|
|
||||||
"🏓 Ping Service Tests",
|
|
||||||
style = MaterialTheme.typography.titleMedium
|
|
||||||
)
|
|
||||||
|
|
||||||
var isDarkMode by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
) {
|
||||||
Button(onClick = { /* TODO: Health Check */ }) {
|
Text("Profil / Status", style = MaterialTheme.typography.headlineMedium)
|
||||||
Text("Health Check")
|
|
||||||
}
|
|
||||||
Button(onClick = { /* TODO: Ping Normal */ }) {
|
|
||||||
Text("Ping Normal")
|
|
||||||
}
|
|
||||||
Button(onClick = { isDarkMode = !isDarkMode }) {
|
|
||||||
Text("Toggle Dark Mode")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text("Dark Mode: ${if(isDarkMode) "🌙 Enabled" else "☀️ Disabled"}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
Text(
|
if (authState.isAuthenticated) {
|
||||||
"✅ System Status",
|
Text("Du bist als ${authState.username ?: authState.userId ?: "unbekannt"} angemeldet.")
|
||||||
style = MaterialTheme.typography.titleMedium
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Button(onClick = {
|
||||||
|
authTokenManager.clearToken()
|
||||||
|
onBackToHome()
|
||||||
|
}) { Text("Abmelden") }
|
||||||
|
} else {
|
||||||
|
Text("Nicht angemeldet.")
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Button(onClick = onBackToHome) { Text("Zurück zur Startseite") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoginScreen(
|
||||||
|
authTokenManager: AuthTokenManager,
|
||||||
|
onLoginSuccess: () -> Unit
|
||||||
|
) {
|
||||||
|
var username by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var isLoading by remember { mutableStateOf(false) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val api = remember { AuthApiClient() }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text("Anmeldung", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = username,
|
||||||
|
onValueChange = { username = it },
|
||||||
|
label = { Text("Benutzername") },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !isLoading,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
Text("Frontend: 🟢 Running")
|
|
||||||
Text("Backend: ⚠️ Testing needed")
|
OutlinedTextField(
|
||||||
Text("Build: ✅ Successful")
|
value = password,
|
||||||
}
|
onValueChange = { password = it },
|
||||||
|
label = { Text("Passwort") },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !isLoading,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
error?.let {
|
||||||
|
Text(it, color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
error = null
|
||||||
|
isLoading = true
|
||||||
|
scope.launch {
|
||||||
|
val res = api.login(username.trim(), password)
|
||||||
|
val token = res.token
|
||||||
|
if (res.success && token != null) {
|
||||||
|
authTokenManager.setToken(token)
|
||||||
|
isLoading = false
|
||||||
|
onLoginSuccess()
|
||||||
|
} else {
|
||||||
|
isLoading = false
|
||||||
|
error = res.message ?: "Login fehlgeschlagen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !isLoading && username.isNotBlank() && password.isNotBlank()
|
||||||
|
) { Text(if (isLoading) "Bitte warten…" else "Login") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
package at.mocode.clients.app
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import at.mocode.clients.shared.commonui.components.AppHeader
|
|
||||||
import at.mocode.clients.shared.commonui.components.AppScaffold
|
|
||||||
import at.mocode.clients.shared.commonui.theme.AppTheme
|
|
||||||
import at.mocode.clients.shared.navigation.AppScreen
|
|
||||||
import at.mocode.clients.pingfeature.PingScreen
|
|
||||||
import at.mocode.clients.pingfeature.PingViewModel
|
|
||||||
import at.mocode.clients.authfeature.LoginScreen
|
|
||||||
import at.mocode.clients.authfeature.AuthTokenManager
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun App() {
|
|
||||||
var currentScreen: AppScreen by remember { mutableStateOf(AppScreen.Home) }
|
|
||||||
// Create a single PingViewModel instance for the lifetime of the App composition.
|
|
||||||
val pingViewModel: PingViewModel = remember { PingViewModel() }
|
|
||||||
// Create a single AuthTokenManager instance for the lifetime of the App composition.
|
|
||||||
val authTokenManager: AuthTokenManager = remember { AuthTokenManager() }
|
|
||||||
// Observe authentication state
|
|
||||||
val authState by authTokenManager.authState.collectAsState()
|
|
||||||
|
|
||||||
AppTheme {
|
|
||||||
AppScaffold(
|
|
||||||
header = {
|
|
||||||
AppHeader(
|
|
||||||
title = "Meldestelle",
|
|
||||||
onNavigateToPing = { currentScreen = AppScreen.Ping },
|
|
||||||
onNavigateToLogin = { currentScreen = AppScreen.Login },
|
|
||||||
onLogout = {
|
|
||||||
authTokenManager.clearToken()
|
|
||||||
currentScreen = AppScreen.Home
|
|
||||||
},
|
|
||||||
isAuthenticated = authState.isAuthenticated,
|
|
||||||
username = authState.username,
|
|
||||||
userPermissions = authState.permissions.map { it.name }
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ paddingValues ->
|
|
||||||
Box(modifier = Modifier.padding(paddingValues)) {
|
|
||||||
when (currentScreen) {
|
|
||||||
is AppScreen.Home -> {
|
|
||||||
LandingScreen(authTokenManager = authTokenManager)
|
|
||||||
}
|
|
||||||
|
|
||||||
is AppScreen.Login -> {
|
|
||||||
LoginScreen(
|
|
||||||
authTokenManager = authTokenManager,
|
|
||||||
onLoginSuccess = { currentScreen = AppScreen.Home }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is AppScreen.Ping -> {
|
|
||||||
PingScreen(viewModel = pingViewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
package at.mocode.clients.app
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import at.mocode.clients.authfeature.AuthTokenManager
|
|
||||||
import at.mocode.clients.authfeature.Permission
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LandingScreen(
|
|
||||||
authTokenManager: AuthTokenManager? = null
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(32.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Top
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Willkommen bei Meldestelle",
|
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Eine moderne, skalierbare Frontend-Architektur",
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "Diese Anwendung demonstriert eine \"Shell + Feature-Module\"-Architektur " +
|
|
||||||
"basierend auf Kotlin Multiplatform. Sie spiegelt die DDD-Struktur des Backends " +
|
|
||||||
"wider und ist als native Desktop-Anwendung (JVM) und Web-Anwendung (JS/Wasm) lauffähig.",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.2
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "🚀 Technologien:",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
TechItem("Kotlin Multiplatform")
|
|
||||||
TechItem("Jetpack Compose Multiplatform")
|
|
||||||
TechItem("Material Design 3")
|
|
||||||
TechItem("Ktor Client")
|
|
||||||
TechItem("Domain-Driven Design")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "Verwenden Sie das Ping Service Menü oben, um die API-Funktionalität zu testen.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
// Permission-based UI demonstration
|
|
||||||
authTokenManager?.let { tokenManager ->
|
|
||||||
val authState by tokenManager.authState.collectAsState()
|
|
||||||
|
|
||||||
if (authState.isAuthenticated && authState.permissions.isNotEmpty()) {
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
|
||||||
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "🔐 Verfügbare Funktionen",
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// Admin features (visible only to users with delete permissions)
|
|
||||||
if (tokenManager.isAdmin()) {
|
|
||||||
PermissionCard(
|
|
||||||
title = "👑 Administrator-Bereich",
|
|
||||||
description = "Vollzugriff auf alle System-Funktionen",
|
|
||||||
permissions = listOf("Alle Berechtigungen", "System-Verwaltung", "Benutzer-Management"),
|
|
||||||
backgroundColor = MaterialTheme.colorScheme.errorContainer,
|
|
||||||
textColor = MaterialTheme.colorScheme.onErrorContainer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Management features (visible to users with create/update permissions)
|
|
||||||
if (tokenManager.canCreate() || tokenManager.canUpdate()) {
|
|
||||||
PermissionCard(
|
|
||||||
title = "✏️ Verwaltung",
|
|
||||||
description = "Erstellen und bearbeiten von Daten",
|
|
||||||
permissions = buildList {
|
|
||||||
if (tokenManager.hasPermission(Permission.PERSON_CREATE)) add("Personen erstellen")
|
|
||||||
if (tokenManager.hasPermission(Permission.PERSON_UPDATE)) add("Personen bearbeiten")
|
|
||||||
if (tokenManager.hasPermission(Permission.VEREIN_CREATE)) add("Vereine erstellen")
|
|
||||||
if (tokenManager.hasPermission(Permission.VEREIN_UPDATE)) add("Vereine bearbeiten")
|
|
||||||
if (tokenManager.hasPermission(Permission.PFERD_CREATE)) add("Pferde erstellen")
|
|
||||||
if (tokenManager.hasPermission(Permission.PFERD_UPDATE)) add("Pferde bearbeiten")
|
|
||||||
if (tokenManager.hasPermission(Permission.VERANSTALTUNG_CREATE)) add("Veranstaltungen erstellen")
|
|
||||||
if (tokenManager.hasPermission(Permission.VERANSTALTUNG_UPDATE)) add("Veranstaltungen bearbeiten")
|
|
||||||
},
|
|
||||||
backgroundColor = MaterialTheme.colorScheme.primaryContainer,
|
|
||||||
textColor = MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read-only features (visible to all authenticated users)
|
|
||||||
if (tokenManager.canRead()) {
|
|
||||||
PermissionCard(
|
|
||||||
title = "👁️ Ansicht",
|
|
||||||
description = "Nur-Lese-Zugriff auf Daten",
|
|
||||||
permissions = buildList {
|
|
||||||
if (tokenManager.hasPermission(Permission.PERSON_READ)) add("Personen anzeigen")
|
|
||||||
if (tokenManager.hasPermission(Permission.VEREIN_READ)) add("Vereine anzeigen")
|
|
||||||
if (tokenManager.hasPermission(Permission.PFERD_READ)) add("Pferde anzeigen")
|
|
||||||
if (tokenManager.hasPermission(Permission.VERANSTALTUNG_READ)) add("Veranstaltungen anzeigen")
|
|
||||||
},
|
|
||||||
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
textColor = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TechItem(text: String) {
|
|
||||||
Text(
|
|
||||||
text = "• $text",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier.padding(vertical = 2.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PermissionCard(
|
|
||||||
title: String,
|
|
||||||
description: String,
|
|
||||||
permissions: List<String>,
|
|
||||||
backgroundColor: androidx.compose.ui.graphics.Color,
|
|
||||||
textColor: androidx.compose.ui.graphics.Color
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 8.dp),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = backgroundColor
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
|
|
||||||
if (permissions.isNotEmpty()) {
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
permissions.forEach { permission ->
|
|
||||||
Text(
|
|
||||||
text = "✓ $permission",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = textColor,
|
|
||||||
modifier = Modifier.padding(vertical = 2.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
package screens
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import at.mocode.clients.shared.presentation.store.AppStore
|
|
||||||
import at.mocode.clients.shared.presentation.state.AppState
|
|
||||||
import at.mocode.clients.pingfeature.PingViewModel
|
|
||||||
import at.mocode.ping.api.HealthResponse
|
|
||||||
import at.mocode.ping.api.PingResponse
|
|
||||||
import at.mocode.ping.api.EnhancedPingResponse
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DevelopmentScreen(appStore: AppStore) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"🚀 Meldestelle Development Mode",
|
|
||||||
style = MaterialTheme.typography.headlineMedium
|
|
||||||
)
|
|
||||||
|
|
||||||
// Backend Connectivity Tests
|
|
||||||
BackendTestSection()
|
|
||||||
|
|
||||||
// Ping Service Test
|
|
||||||
PingTestSection()
|
|
||||||
|
|
||||||
// State Debugging
|
|
||||||
StateDebugSection(appStore)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun BackendTestSection() {
|
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
|
||||||
Text("🌐 Backend Connectivity", style = MaterialTheme.typography.titleMedium)
|
|
||||||
|
|
||||||
var testStatus by remember { mutableStateOf("Not tested") }
|
|
||||||
var isLoading by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
// TODO: Test Gateway Connection
|
|
||||||
isLoading = true
|
|
||||||
testStatus = "Testing..."
|
|
||||||
},
|
|
||||||
enabled = !isLoading
|
|
||||||
) {
|
|
||||||
Text("Test Gateway")
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
// TODO: Test Ping Service Direct
|
|
||||||
isLoading = true
|
|
||||||
testStatus = "Testing direct connection..."
|
|
||||||
},
|
|
||||||
enabled = !isLoading
|
|
||||||
) {
|
|
||||||
Text("Test Ping Service")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.padding(8.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
Text("Status: $testStatus")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PingTestSection() {
|
|
||||||
val pingViewModel = remember { PingViewModel() }
|
|
||||||
val uiState = pingViewModel.uiState
|
|
||||||
|
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
|
||||||
Text("🏓 Ping Service Integration", style = MaterialTheme.typography.titleMedium)
|
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Button(
|
|
||||||
onClick = { pingViewModel.performHealthCheck() },
|
|
||||||
enabled = !uiState.isLoading
|
|
||||||
) {
|
|
||||||
Text("Health Check")
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = { pingViewModel.performSimplePing() },
|
|
||||||
enabled = !uiState.isLoading
|
|
||||||
) {
|
|
||||||
Text("Simple Ping")
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = { pingViewModel.performEnhancedPing(true) },
|
|
||||||
enabled = !uiState.isLoading
|
|
||||||
) {
|
|
||||||
Text("Test Circuit Breaker")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uiState.isLoading) {
|
|
||||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Results Display
|
|
||||||
uiState.healthResponse?.let { health ->
|
|
||||||
Card(
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(8.dp)) {
|
|
||||||
Text("✅ Health Check Result:")
|
|
||||||
Text("Status: ${health.status}")
|
|
||||||
Text("Service: ${health.service}")
|
|
||||||
Text("Healthy: ${health.healthy}")
|
|
||||||
Text("Timestamp: ${health.timestamp}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
uiState.simplePingResponse?.let { ping ->
|
|
||||||
Card(
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(8.dp)) {
|
|
||||||
Text("🏓 Simple Ping Result:")
|
|
||||||
Text("Status: ${ping.status}")
|
|
||||||
Text("Service: ${ping.service}")
|
|
||||||
Text("Timestamp: ${ping.timestamp}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
uiState.enhancedPingResponse?.let { ping ->
|
|
||||||
Card(
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(8.dp)) {
|
|
||||||
Text("⚡ Enhanced Ping Result:")
|
|
||||||
Text("Status: ${ping.status}")
|
|
||||||
Text("Circuit Breaker: ${ping.circuitBreakerState}")
|
|
||||||
Text("Response Time: ${ping.responseTime}ms")
|
|
||||||
Text("Service: ${ping.service}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
uiState.errorMessage?.let { error ->
|
|
||||||
Card(
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"❌ Error: $error",
|
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
|
||||||
modifier = Modifier.padding(8.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun StateDebugSection(appStore: AppStore) {
|
|
||||||
val appState by appStore.state.collectAsState()
|
|
||||||
|
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
|
||||||
Text("🔍 App State Debug", style = MaterialTheme.typography.titleMedium)
|
|
||||||
|
|
||||||
Text("Auth State: ${if(appState.auth.isAuthenticated) "✅ Authenticated" else "❌ Not Authenticated"}")
|
|
||||||
Text("Current Route: ${appState.navigation.currentRoute}")
|
|
||||||
Text("Dark Mode: ${if(appState.ui.isDarkMode) "🌙 Enabled" else "☀️ Disabled"}")
|
|
||||||
Text("Online: ${if(appState.network.isOnline) "🟢 Online" else "🔴 Offline"}")
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
appStore.dispatch(at.mocode.clients.shared.presentation.actions.AppAction.UI.ToggleDarkMode)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text("Toggle Dark Mode")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class ComposeAppCommonTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun example() {
|
||||||
|
assertEquals(3, 1 + 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,37 @@
|
|||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.window.ComposeViewport
|
import androidx.compose.ui.window.ComposeViewport
|
||||||
import kotlinx.browser.document
|
import kotlinx.browser.document
|
||||||
import kotlinx.browser.window
|
|
||||||
import org.w3c.dom.HTMLElement
|
import org.w3c.dom.HTMLElement
|
||||||
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
fun main() {
|
fun main() {
|
||||||
window.onload = {
|
console.log("[WebApp] main() entered")
|
||||||
|
fun startApp() {
|
||||||
try {
|
try {
|
||||||
|
console.log("[WebApp] startApp(): readyState=", document.asDynamic().readyState)
|
||||||
val root = document.getElementById("ComposeTarget") as HTMLElement
|
val root = document.getElementById("ComposeTarget") as HTMLElement
|
||||||
|
console.log("[WebApp] ComposeTarget exists? ", (root != null))
|
||||||
ComposeViewport(root) {
|
ComposeViewport(root) {
|
||||||
MainApp()
|
MainApp()
|
||||||
}
|
}
|
||||||
|
// Remove the static loading placeholder if present
|
||||||
|
(document.querySelector(".loading") as? HTMLElement)?.let { it.parentElement?.removeChild(it) }
|
||||||
|
console.log("[WebApp] ComposeViewport mounted, loading placeholder removed")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
console.error("Failed to start Compose Web app", e)
|
console.error("Failed to start Compose Web app", e)
|
||||||
document.getElementById("root")?.innerHTML =
|
val fallbackTarget = (document.getElementById("ComposeTarget") ?: document.body) as HTMLElement
|
||||||
|
fallbackTarget.innerHTML =
|
||||||
"<div style='padding: 50px; text-align: center;'>❌ Failed to load app: ${e.message}</div>"
|
"<div style='padding: 50px; text-align: center;'>❌ Failed to load app: ${e.message}</div>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start immediately if DOM is already parsed, otherwise wait for DOMContentLoaded.
|
||||||
|
val state = document.asDynamic().readyState as String?
|
||||||
|
if (state == "interactive" || state == "complete") {
|
||||||
|
console.log("[WebApp] DOM already ready (", state, ") → starting immediately")
|
||||||
|
startApp()
|
||||||
|
} else {
|
||||||
|
console.log("[WebApp] Waiting for DOMContentLoaded, current state:", state)
|
||||||
|
document.addEventListener("DOMContentLoaded", { startApp() })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,38 +2,26 @@
|
|||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>Meldestelle - Web Development</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<style>
|
<title>Meldestelle - Web</title>
|
||||||
body {
|
<link type="text/css" rel="stylesheet" href="styles.css">
|
||||||
margin: 0;
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
padding: 0;
|
<meta name="theme-color" content="#0f172a">
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
|
||||||
background: #fafafa;
|
|
||||||
}
|
|
||||||
#ComposeTarget {
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
#root {
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
.loading {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
height: 100vh;
|
|
||||||
font-size: 18px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root">
|
<div id="ComposeTarget">
|
||||||
<canvas id="ComposeTarget"></canvas>
|
<div class="loading">Loading...</div>
|
||||||
<div class="loading">🚀 Loading Meldestelle...</div>
|
|
||||||
</div>
|
</div>
|
||||||
<script src="web-app.js"></script>
|
<script src="web-app.js"></script>
|
||||||
|
<script>
|
||||||
|
// Register Service Worker only in non-localhost environments
|
||||||
|
if ('serviceWorker' in navigator && !['localhost', '127.0.0.1', '::1'].includes(location.hostname)) {
|
||||||
|
window.addEventListener('load', function() {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(function(err){
|
||||||
|
console.warn('ServiceWorker registration failed:', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
html, body {
|
html, body {
|
||||||
height: 100vh;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
background: #fafafa;
|
||||||
overflow: hidden; /* Verhindert Scrollbalken durch die Canvas */
|
overflow: hidden; /* Verhindert Scrollbalken durch die Canvas */
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10,3 +11,12 @@ html, body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ self.addEventListener('fetch', (event) => {
|
|||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
if (resp && resp.status === 200 && resp.type === 'basic') {
|
if (resp && resp.status === 200 && resp.type === 'basic') {
|
||||||
const copy = resp.clone();
|
const copy = resp.clone();
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.put('/index.html', copy)).catch(() => {});
|
caches.open(CACHE_NAME).then((cache) => cache.put('/index.html', copy)).catch(() => {
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return resp;
|
return resp;
|
||||||
})
|
})
|
||||||
@@ -83,7 +84,8 @@ self.addEventListener('fetch', (event) => {
|
|||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
if (resp && resp.status === 200 && resp.type === 'basic') {
|
if (resp && resp.status === 200 && resp.type === 'basic') {
|
||||||
const copy = resp.clone();
|
const copy = resp.clone();
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.put(req, copy)).catch(() => {});
|
caches.open(CACHE_NAME).then((cache) => cache.put(req, copy)).catch(() => {
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return resp;
|
return resp;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.window.ComposeViewport
|
import androidx.compose.ui.window.ComposeViewport
|
||||||
import at.mocode.clients.app.App
|
|
||||||
import kotlinx.browser.document
|
import kotlinx.browser.document
|
||||||
import org.w3c.dom.HTMLElement
|
import org.w3c.dom.HTMLElement
|
||||||
|
|
||||||
@@ -8,6 +7,6 @@ import org.w3c.dom.HTMLElement
|
|||||||
fun main() {
|
fun main() {
|
||||||
val root = document.getElementById("ComposeTarget") as HTMLElement
|
val root = document.getElementById("ComposeTarget") as HTMLElement
|
||||||
ComposeViewport(root) {
|
ComposeViewport(root) {
|
||||||
App()
|
MainApp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,20 +16,17 @@ if (process.env.ANALYZE_BUNDLE === 'true') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Weitere Optimierungen hinzufügen (erweitert bestehende config)
|
// Hinweis: Wir liefern eine statische index.html aus src/jsMain/resources aus.
|
||||||
config.optimization = {
|
// Diese Datei enthält nur einen Script-Tag zu "web-app.js" und wird NICHT
|
||||||
...config.optimization, // Behalte Kotlin/JS Optimierungen
|
// vom HtmlWebpackPlugin generiert. Zusätzliche Chunks (z. B. vendor/runtime)
|
||||||
splitChunks: {
|
// würden dann nicht automatisch injiziert und führen dazu, dass die App nicht startet
|
||||||
chunks: 'all',
|
// (Bildschirm bleibt auf "Loading...").
|
||||||
cacheGroups: {
|
//
|
||||||
vendor: {
|
// Daher überschreiben wir config.optimization NICHT mehr mit splitChunks.
|
||||||
test: /[\\/]node_modules[\\/]/,
|
// Wenn später Chunking gewünscht ist, muss die index.html durch die generierte
|
||||||
name: 'vendor',
|
// HTML ersetzt oder die zusätzlichen Chunks manuell eingebunden werden.
|
||||||
chunks: 'all'
|
//
|
||||||
}
|
// (Frühere splitChunks-Konfiguration wurde bewusst entfernt.)
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Development Server Konfiguration erweitern
|
// Development Server Konfiguration erweitern
|
||||||
if (config.devServer) {
|
if (config.devServer) {
|
||||||
|
|||||||
@@ -28,12 +28,16 @@ kotlin {
|
|||||||
enabled = false
|
enabled = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
binaries.executable()
|
||||||
}
|
}
|
||||||
|
|
||||||
// WASM, nur wenn explizit aktiviert
|
// WASM, nur wenn explizit aktiviert
|
||||||
if (enableWasm) {
|
if (enableWasm) {
|
||||||
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
||||||
wasmJs { browser() }
|
wasmJs {
|
||||||
|
browser()
|
||||||
|
binaries.executable()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
@@ -116,7 +120,9 @@ tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
|
|||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(JvmTarget.JVM_21)
|
jvmTarget.set(JvmTarget.JVM_21)
|
||||||
freeCompilerArgs.addAll(
|
freeCompilerArgs.addAll(
|
||||||
"-opt-in=kotlin.RequiresOptIn"
|
"-opt-in=kotlin.RequiresOptIn",
|
||||||
|
// Suppress beta warning for expect/actual classes as per project decision
|
||||||
|
"-Xexpect-actual-classes"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-6
@@ -1,11 +1,9 @@
|
|||||||
package at.mocode.clients.authfeature
|
package at.mocode.clients.authfeature
|
||||||
|
|
||||||
import at.mocode.clients.shared.AppConfig
|
import at.mocode.clients.shared.core.AppConstants
|
||||||
import io.ktor.client.call.*
|
import io.ktor.client.call.*
|
||||||
import io.ktor.client.request.*
|
|
||||||
import io.ktor.client.request.forms.*
|
import io.ktor.client.request.forms.*
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.http.content.*
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,11 +29,11 @@ data class LoginResponse(
|
|||||||
*/
|
*/
|
||||||
class AuthApiClient(
|
class AuthApiClient(
|
||||||
// Keycloak Basis-URL (z. B. http://localhost:8180)
|
// Keycloak Basis-URL (z. B. http://localhost:8180)
|
||||||
private val keycloakBaseUrl: String = AppConfig.KEYCLOAK_URL,
|
private val keycloakBaseUrl: String = AppConstants.KEYCLOAK_URL,
|
||||||
// Realm-Name in Keycloak
|
// Realm-Name in Keycloak
|
||||||
private val realm: String = AppConfig.KEYCLOAK_REALM,
|
private val realm: String = AppConstants.KEYCLOAK_REALM,
|
||||||
// Client-ID (Public Client empfohlen für Frontend-Flows)
|
// Client-ID (Public Client empfohlen für Frontend-Flows)
|
||||||
private val clientId: String = AppConfig.KEYCLOAK_CLIENT_ID,
|
private val clientId: String = AppConstants.KEYCLOAK_CLIENT_ID,
|
||||||
// Optional: Client-Secret (nur bei vertraulichen Clients erforderlich)
|
// Optional: Client-Secret (nur bei vertraulichen Clients erforderlich)
|
||||||
private val clientSecret: String? = null
|
private val clientSecret: String? = null
|
||||||
) {
|
) {
|
||||||
@@ -86,6 +84,49 @@ class AuthApiClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = client.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
|
* Refresh authentication token
|
||||||
*/
|
*/
|
||||||
|
|||||||
-6
@@ -5,12 +5,6 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonArray
|
|
||||||
import kotlinx.serialization.json.JsonObject
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
import kotlinx.serialization.json.longOrNull
|
|
||||||
import kotlinx.serialization.json.contentOrNull
|
|
||||||
import kotlin.io.encoding.Base64
|
import kotlin.io.encoding.Base64
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.time.ExperimentalTime
|
import kotlin.time.ExperimentalTime
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
package at.mocode.clients.authfeature
|
package at.mocode.clients.authfeature
|
||||||
|
|
||||||
import at.mocode.clients.shared.AppConfig
|
import at.mocode.clients.shared.core.AppConstants
|
||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
import io.ktor.client.plugins.contentnegotiation.*
|
import io.ktor.client.plugins.contentnegotiation.*
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
@@ -19,7 +19,7 @@ object AuthenticatedHttpClient {
|
|||||||
/**
|
/**
|
||||||
* Create a basic HTTP client with JSON support
|
* Create a basic HTTP client with JSON support
|
||||||
*/
|
*/
|
||||||
fun create(baseUrl: String = AppConfig.GATEWAY_URL): HttpClient {
|
fun create(baseUrl: String = AppConstants.GATEWAY_URL): HttpClient {
|
||||||
return HttpClient {
|
return HttpClient {
|
||||||
install(ContentNegotiation) {
|
install(ContentNegotiation) {
|
||||||
json(Json {
|
json(Json {
|
||||||
|
|||||||
+3
-4
@@ -2,14 +2,13 @@ package at.mocode.clients.authfeature
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import at.mocode.clients.shared.AppConfig
|
import at.mocode.clients.authfeature.AuthenticatedHttpClient.addAuthHeader
|
||||||
import io.ktor.client.call.*
|
import at.mocode.clients.shared.core.AppConstants
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import at.mocode.clients.authfeature.AuthenticatedHttpClient.addAuthHeader
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UI state for the login screen
|
* UI state for the login screen
|
||||||
@@ -98,7 +97,7 @@ class LoginViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val client = AuthenticatedHttpClient.create()
|
val client = AuthenticatedHttpClient.create()
|
||||||
client.post("${AppConfig.GATEWAY_URL}/api/members/sync") {
|
client.post("${AppConstants.GATEWAY_URL}/api/members/sync") {
|
||||||
addAuthHeader()
|
addAuthHeader()
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
|
|||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
package at.mocode.clients.authfeature.oauth
|
||||||
|
|
||||||
|
data class CallbackParams(val code: String, val state: String?)
|
||||||
|
|
||||||
|
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?
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package at.mocode.clients.authfeature.oauth
|
||||||
|
|
||||||
|
import at.mocode.clients.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.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package at.mocode.clients.authfeature.oauth
|
||||||
|
|
||||||
|
import kotlinx.browser.window
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
package at.mocode.clients.authfeature.oauth
|
||||||
|
|
||||||
|
import at.mocode.clients.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.Default.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>
|
||||||
|
}
|
||||||
|
|
||||||
|
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
@@ -0,0 +1,5 @@
|
|||||||
|
package at.mocode.clients.authfeature.oauth
|
||||||
|
|
||||||
|
actual object AuthCallbackParams {
|
||||||
|
actual fun parse(): CallbackParams? = null
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package at.mocode.clients.authfeature.oauth
|
||||||
|
|
||||||
|
import at.mocode.clients.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,87 +0,0 @@
|
|||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
||||||
|
|
||||||
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 {
|
|
||||||
val enableWasm = providers.gradleProperty("enableWasm").orNull == "true"
|
|
||||||
|
|
||||||
jvmToolchain(21)
|
|
||||||
|
|
||||||
jvm()
|
|
||||||
|
|
||||||
js {
|
|
||||||
browser {
|
|
||||||
testTask { enabled = false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enableWasm) {
|
|
||||||
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
|
||||||
wasmJs { browser() }
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceSets {
|
|
||||||
commonMain.dependencies {
|
|
||||||
// UI Kit
|
|
||||||
implementation(project(":clients:shared:common-ui"))
|
|
||||||
// Shared config/utilities
|
|
||||||
implementation(project(":clients:shared"))
|
|
||||||
// Authentication helpers (token + authenticated client)
|
|
||||||
implementation(project(":clients:auth-feature"))
|
|
||||||
|
|
||||||
// Compose
|
|
||||||
implementation(compose.runtime)
|
|
||||||
implementation(compose.foundation)
|
|
||||||
implementation(compose.material3)
|
|
||||||
implementation(compose.ui)
|
|
||||||
implementation(compose.components.resources)
|
|
||||||
implementation(compose.materialIconsExtended)
|
|
||||||
|
|
||||||
// ViewModel lifecycle + compose helpers
|
|
||||||
implementation(libs.bundles.compose.common)
|
|
||||||
|
|
||||||
// HTTP + Kotlinx
|
|
||||||
implementation(libs.bundles.ktor.client.common)
|
|
||||||
implementation(libs.bundles.kotlinx.core)
|
|
||||||
}
|
|
||||||
|
|
||||||
commonTest.dependencies {
|
|
||||||
implementation(libs.kotlin.test)
|
|
||||||
implementation(libs.kotlinx.coroutines.test)
|
|
||||||
implementation(libs.ktor.client.mock)
|
|
||||||
}
|
|
||||||
|
|
||||||
jvmMain.dependencies {
|
|
||||||
implementation(libs.ktor.client.cio)
|
|
||||||
}
|
|
||||||
|
|
||||||
jsMain.dependencies {
|
|
||||||
implementation(libs.ktor.client.js)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enableWasm) {
|
|
||||||
val wasmJsMain = getByName("wasmJsMain")
|
|
||||||
wasmJsMain.dependencies {
|
|
||||||
implementation(libs.ktor.client.js)
|
|
||||||
implementation(compose.runtime)
|
|
||||||
implementation(compose.foundation)
|
|
||||||
implementation(compose.material3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
|
|
||||||
compilerOptions {
|
|
||||||
jvmTarget.set(JvmTarget.JVM_21)
|
|
||||||
freeCompilerArgs.addAll("-opt-in=kotlin.RequiresOptIn")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-37
@@ -1,37 +0,0 @@
|
|||||||
package at.mocode.clients.membersfeature
|
|
||||||
|
|
||||||
import at.mocode.clients.authfeature.AuthenticatedHttpClient
|
|
||||||
import at.mocode.clients.shared.AppConfig
|
|
||||||
import at.mocode.clients.membersfeature.model.MemberProfile
|
|
||||||
import io.ktor.client.call.*
|
|
||||||
import io.ktor.client.request.*
|
|
||||||
import io.ktor.http.*
|
|
||||||
import at.mocode.clients.authfeature.AuthenticatedHttpClient.addAuthHeader
|
|
||||||
|
|
||||||
class MembersApiClient(
|
|
||||||
private val baseUrl: String = AppConfig.GATEWAY_URL
|
|
||||||
) {
|
|
||||||
private val client = AuthenticatedHttpClient.create()
|
|
||||||
|
|
||||||
suspend fun getMyProfile(): MemberProfile {
|
|
||||||
// Erwarteter Endpoint: GET /api/members/me
|
|
||||||
return client.get("$baseUrl/api/members/me") {
|
|
||||||
addAuthHeader()
|
|
||||||
}.body()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optionaler Convenience-Call: Löst den Backend-Sync einmalig aus.
|
|
||||||
* Gibt true zurück, wenn der Call erfolgreich war (HTTP 2xx), sonst false.
|
|
||||||
*/
|
|
||||||
suspend fun syncProfile(): Boolean {
|
|
||||||
return try {
|
|
||||||
val response = client.post("$baseUrl/api/members/sync") {
|
|
||||||
addAuthHeader()
|
|
||||||
}
|
|
||||||
response.status.isSuccess()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-63
@@ -1,63 +0,0 @@
|
|||||||
package at.mocode.clients.membersfeature
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ProfileScreen(viewModel: ProfileViewModel) {
|
|
||||||
val state by viewModel.uiState.collectAsState()
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
viewModel.loadProfile()
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Text(text = "👤 Mein Profil", style = MaterialTheme.typography.headlineSmall)
|
|
||||||
|
|
||||||
if (state.isLoading) {
|
|
||||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
state.errorMessage?.let { error ->
|
|
||||||
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
|
||||||
Text("Fehler", style = MaterialTheme.typography.titleMedium)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Text(error)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Button(onClick = { viewModel.clearError() }) { Text("Schließen") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
state.profile?.let { profile ->
|
|
||||||
Card {
|
|
||||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Text(text = profile.fullName.ifBlank { "Unbekannt" }, style = MaterialTheme.typography.titleLarge)
|
|
||||||
profile.username?.let { Text("Benutzername: $it") }
|
|
||||||
profile.email?.let { Text("E-Mail: $it") }
|
|
||||||
if (profile.roles.isNotEmpty()) {
|
|
||||||
Text("Rollen: ${profile.roles.joinToString(", ")}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Button(onClick = { viewModel.loadProfile() }, enabled = !state.isLoading) {
|
|
||||||
Text("Neu laden")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-42
@@ -1,42 +0,0 @@
|
|||||||
package at.mocode.clients.membersfeature
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import at.mocode.clients.membersfeature.model.MemberProfile
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
data class ProfileUiState(
|
|
||||||
val isLoading: Boolean = false,
|
|
||||||
val profile: MemberProfile? = null,
|
|
||||||
val errorMessage: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
class ProfileViewModel(
|
|
||||||
private val api: MembersApiClient = MembersApiClient()
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ProfileUiState())
|
|
||||||
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
|
|
||||||
|
|
||||||
fun loadProfile() {
|
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val profile = api.getMyProfile()
|
|
||||||
_uiState.value = ProfileUiState(isLoading = false, profile = profile)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_uiState.value = ProfileUiState(
|
|
||||||
isLoading = false,
|
|
||||||
errorMessage = e.message ?: "Profil konnte nicht geladen werden"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearError() {
|
|
||||||
_uiState.value = _uiState.value.copy(errorMessage = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
package at.mocode.clients.membersfeature.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class MemberProfile(
|
|
||||||
val id: String? = null,
|
|
||||||
val username: String? = null,
|
|
||||||
val email: String? = null,
|
|
||||||
val firstName: String? = null,
|
|
||||||
val lastName: String? = null,
|
|
||||||
val roles: List<String> = emptyList()
|
|
||||||
) {
|
|
||||||
val fullName: String
|
|
||||||
get() = listOfNotNull(firstName, lastName).joinToString(" ").ifBlank { username ?: "" }
|
|
||||||
}
|
|
||||||
@@ -28,12 +28,16 @@ kotlin {
|
|||||||
enabled = false
|
enabled = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
binaries.executable()
|
||||||
}
|
}
|
||||||
|
|
||||||
// WASM, nur wenn explizit aktiviert
|
// WASM, nur wenn explizit aktiviert
|
||||||
if (enableWasm) {
|
if (enableWasm) {
|
||||||
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
||||||
wasmJs { browser() }
|
wasmJs {
|
||||||
|
browser()
|
||||||
|
binaries.executable()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@ import at.mocode.ping.api.PingApi
|
|||||||
import at.mocode.ping.api.PingResponse
|
import at.mocode.ping.api.PingResponse
|
||||||
import at.mocode.ping.api.EnhancedPingResponse
|
import at.mocode.ping.api.EnhancedPingResponse
|
||||||
import at.mocode.ping.api.HealthResponse
|
import at.mocode.ping.api.HealthResponse
|
||||||
import at.mocode.clients.shared.AppConfig
|
import at.mocode.clients.shared.core.AppConstants
|
||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
import io.ktor.client.call.*
|
import io.ktor.client.call.*
|
||||||
import io.ktor.client.plugins.contentnegotiation.*
|
import io.ktor.client.plugins.contentnegotiation.*
|
||||||
@@ -13,7 +13,7 @@ import io.ktor.serialization.kotlinx.json.*
|
|||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
class PingApiClient(
|
class PingApiClient(
|
||||||
private val baseUrl: String = AppConfig.GATEWAY_URL
|
private val baseUrl: String = AppConstants.GATEWAY_URL
|
||||||
) : PingApi {
|
) : PingApi {
|
||||||
|
|
||||||
private val client = HttpClient {
|
private val client = HttpClient {
|
||||||
|
|||||||
+5
-1
@@ -8,7 +8,7 @@ import kotlinx.coroutines.delay
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* API-Client für Reitsport-Authentication-Testing
|
* API-Client für Reitsport-Authentication-Testing
|
||||||
* Testet verschiedene Services mit rollenbasierten Tokens
|
* testet verschiedene Services mit rollenbasierten Tokens
|
||||||
*/
|
*/
|
||||||
class ReitsportTestApi {
|
class ReitsportTestApi {
|
||||||
|
|
||||||
@@ -41,16 +41,20 @@ class ReitsportTestApi {
|
|||||||
results.add(testMembersService(role))
|
results.add(testMembersService(role))
|
||||||
results.add(testSystemAccess(role))
|
results.add(testSystemAccess(role))
|
||||||
}
|
}
|
||||||
|
|
||||||
RolleE.FUNKTIONAER -> {
|
RolleE.FUNKTIONAER -> {
|
||||||
results.add(testEventsService(role))
|
results.add(testEventsService(role))
|
||||||
results.add(testMembersService(role))
|
results.add(testMembersService(role))
|
||||||
}
|
}
|
||||||
|
|
||||||
RolleE.TIERARZT, RolleE.TRAINER -> {
|
RolleE.TIERARZT, RolleE.TRAINER -> {
|
||||||
results.add(testHorsesService(role))
|
results.add(testHorsesService(role))
|
||||||
}
|
}
|
||||||
|
|
||||||
RolleE.REITER -> {
|
RolleE.REITER -> {
|
||||||
results.add(testMembersService(role))
|
results.add(testMembersService(role))
|
||||||
}
|
}
|
||||||
|
|
||||||
RolleE.RICHTER, RolleE.ZUSCHAUER, RolleE.GAST -> {
|
RolleE.RICHTER, RolleE.ZUSCHAUER, RolleE.GAST -> {
|
||||||
results.add(testPublicAccess(role))
|
results.add(testPublicAccess(role))
|
||||||
}
|
}
|
||||||
|
|||||||
-93
@@ -1,93 +0,0 @@
|
|||||||
package at.mocode.clients.pingfeature.model
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Phase 1 Validierung für Reitsport-Authentication-Testing
|
|
||||||
* Testet alle Erfolgs-Kriterien aus der Aufgabenstellung
|
|
||||||
*/
|
|
||||||
object Phase1Validation {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Führt alle Phase 1 Validierungen durch
|
|
||||||
*/
|
|
||||||
fun validatePhase1(): String {
|
|
||||||
val results = mutableListOf<String>()
|
|
||||||
|
|
||||||
// ✅ Test 1: Anzahl Rollen (erwartet: 9)
|
|
||||||
val roleCount = ReitsportRoles.ALL_ROLES.size
|
|
||||||
results.add("✅ Rollen-Anzahl: $roleCount (erwartet: 9) - ${if (roleCount == 9) "ERFOLG" else "FEHLER"}")
|
|
||||||
|
|
||||||
// ✅ Test 2: Admin-Rolle verfügbar
|
|
||||||
val adminRole = ReitsportRoles.ADMIN
|
|
||||||
results.add("✅ Admin-Rolle: ${adminRole.displayName} - ERFOLG")
|
|
||||||
|
|
||||||
// ✅ Test 3: Alle Kategorien verfügbar
|
|
||||||
val categories = ReitsportRoles.ROLES_BY_CATEGORY.keys
|
|
||||||
results.add("✅ Kategorien: $categories - ERFOLG")
|
|
||||||
results.add(" - SYSTEM: ${ReitsportRoles.ROLES_BY_CATEGORY[RoleCategory.SYSTEM]?.size ?: 0} Rollen")
|
|
||||||
results.add(" - OFFICIAL: ${ReitsportRoles.ROLES_BY_CATEGORY[RoleCategory.OFFICIAL]?.size ?: 0} Rollen")
|
|
||||||
results.add(" - ACTIVE: ${ReitsportRoles.ROLES_BY_CATEGORY[RoleCategory.ACTIVE]?.size ?: 0} Rollen")
|
|
||||||
results.add(" - PASSIVE: ${ReitsportRoles.ROLES_BY_CATEGORY[RoleCategory.PASSIVE]?.size ?: 0} Rollen")
|
|
||||||
|
|
||||||
// ✅ Test 4: DateTime funktioniert
|
|
||||||
val timestamp = DateTimeHelper.now()
|
|
||||||
results.add("✅ DateTime funktioniert: $timestamp - ERFOLG")
|
|
||||||
|
|
||||||
// ✅ Test 5: Test-ID generiert
|
|
||||||
val testId = getTimeMillis().toString()
|
|
||||||
results.add("✅ Test-ID generiert: $testId - ERFOLG")
|
|
||||||
|
|
||||||
// ✅ Test 6: Enum-Zugriff funktioniert
|
|
||||||
results.add("✅ RolleE Enum: ${RolleE.entries.size} Einträge - ERFOLG")
|
|
||||||
results.add("✅ BerechtigungE Enum: ${BerechtigungE.entries.size} Einträge - ERFOLG")
|
|
||||||
|
|
||||||
// ✅ Test 7: Alle 9 Rollen einzeln prüfen
|
|
||||||
results.add("✅ Alle Rollen-Definitionen:")
|
|
||||||
ReitsportRoles.ALL_ROLES.forEachIndexed { index, role ->
|
|
||||||
results.add(" ${index + 1}. ${role.displayName} (${role.roleType}) - ${role.permissions.size} Berechtigungen")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ Test 8: Berechtigungen-Zuordnung testen
|
|
||||||
val adminPermissions = ReitsportRoles.ADMIN.permissions.size
|
|
||||||
val guestPermissions = ReitsportRoles.GAST.permissions.size
|
|
||||||
results.add("✅ Admin-Berechtigungen: $adminPermissions (max)")
|
|
||||||
results.add("✅ Gast-Berechtigungen: $guestPermissions (min)")
|
|
||||||
|
|
||||||
// ✅ Test 9: Hilfsfunktionen testen
|
|
||||||
val roleByType = ReitsportRoles.getRoleByType(RolleE.RICHTER)
|
|
||||||
results.add("✅ Rolle per Type: ${roleByType?.displayName} - ERFOLG")
|
|
||||||
|
|
||||||
val rolesWithRead = ReitsportRoles.getRolesWithPermission(BerechtigungE.PERSON_READ)
|
|
||||||
results.add("✅ Rollen mit PERSON_READ: ${rolesWithRead.size} - ERFOLG")
|
|
||||||
|
|
||||||
return results.joinToString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Führt Performance-Test durch
|
|
||||||
*/
|
|
||||||
fun performanceTest(): String {
|
|
||||||
val start = DateTimeHelper.now()
|
|
||||||
|
|
||||||
// Simuliere mehrere Rollen-Abfragen
|
|
||||||
repeat(100) {
|
|
||||||
ReitsportRoles.getAllRoles()
|
|
||||||
ReitsportRoles.getRoleByType(RolleE.ADMIN)
|
|
||||||
ReitsportRoles.getRolesWithPermission(BerechtigungE.PERSON_READ)
|
|
||||||
}
|
|
||||||
|
|
||||||
val end = DateTimeHelper.now()
|
|
||||||
val duration = end - start
|
|
||||||
|
|
||||||
return "✅ Performance-Test: $duration Zeiteinheiten für 300 Operationen - ERFOLG"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hilfsfunktion für externe Zeitabfrage
|
|
||||||
*/
|
|
||||||
private fun getTimeMillis(): Long = DateTimeHelper.now()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extension für einfacheren Zugriff
|
|
||||||
*/
|
|
||||||
private fun ReitsportRoles.getAllRoles() = ALL_ROLES
|
|
||||||
@@ -58,6 +58,11 @@ kotlin {
|
|||||||
implementation(libs.ktor.client.logging)
|
implementation(libs.ktor.client.logging)
|
||||||
implementation(libs.ktor.client.auth)
|
implementation(libs.ktor.client.auth)
|
||||||
|
|
||||||
|
// Dependency Injection (Koin)
|
||||||
|
implementation(libs.koin.core)
|
||||||
|
implementation(libs.koin.compose)
|
||||||
|
implementation(libs.koin.compose.viewmodel)
|
||||||
|
|
||||||
// Compose für shared UI components (common)
|
// Compose für shared UI components (common)
|
||||||
implementation(compose.runtime)
|
implementation(compose.runtime)
|
||||||
implementation(compose.foundation)
|
implementation(compose.foundation)
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ kotlin {
|
|||||||
jvm()
|
jvm()
|
||||||
js(IR) {
|
js(IR) {
|
||||||
browser()
|
browser()
|
||||||
nodejs()
|
// nodejs()
|
||||||
|
binaries.executable()
|
||||||
}
|
}
|
||||||
|
|
||||||
// WASM, nur wenn explizit aktiviert
|
// WASM, nur wenn explizit aktiviert
|
||||||
@@ -21,6 +22,7 @@ kotlin {
|
|||||||
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
||||||
wasmJs {
|
wasmJs {
|
||||||
browser()
|
browser()
|
||||||
|
binaries.executable()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -1,12 +1,11 @@
|
|||||||
package at.mocode.clients.shared.commonui.components
|
package at.mocode.clients.shared.commonui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
enum class ButtonVariant {
|
enum class ButtonVariant {
|
||||||
|
|||||||
+1
@@ -79,6 +79,7 @@ fun MeldestelleTextField(
|
|||||||
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
|
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
helperText != null -> {
|
helperText != null -> {
|
||||||
Text(
|
Text(
|
||||||
text = helperText,
|
text = helperText,
|
||||||
|
|||||||
+3
-177
@@ -1,179 +1,5 @@
|
|||||||
package at.mocode.clients.shared.commonui.components
|
package at.mocode.clients.shared.commonui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
// Legacy notification components removed due to dependency on old presentation layer.
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
// Intentionally left empty as part of cleanup. You can safely delete this file
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
// if no modules import it anymore.
|
||||||
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.unit.dp
|
|
||||||
import at.mocode.clients.shared.presentation.state.Notification
|
|
||||||
import at.mocode.clients.shared.presentation.state.NotificationType
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun NotificationCard(
|
|
||||||
notification: Notification,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val backgroundColor = when (notification.type) {
|
|
||||||
NotificationType.SUCCESS -> Color(0xFF4CAF50).copy(alpha = 0.1f)
|
|
||||||
NotificationType.ERROR -> Color(0xFFF44336).copy(alpha = 0.1f)
|
|
||||||
NotificationType.WARNING -> Color(0xFFFF9800).copy(alpha = 0.1f)
|
|
||||||
NotificationType.INFO -> Color(0xFF2196F3).copy(alpha = 0.1f)
|
|
||||||
}
|
|
||||||
|
|
||||||
val borderColor = when (notification.type) {
|
|
||||||
NotificationType.SUCCESS -> Color(0xFF4CAF50)
|
|
||||||
NotificationType.ERROR -> Color(0xFFF44336)
|
|
||||||
NotificationType.WARNING -> Color(0xFFFF9800)
|
|
||||||
NotificationType.INFO -> Color(0xFF2196F3)
|
|
||||||
}
|
|
||||||
|
|
||||||
Card(
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = backgroundColor),
|
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
border = androidx.compose.foundation.BorderStroke(1.dp, borderColor)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalAlignment = Alignment.Top
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = notification.title,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
|
|
||||||
if (notification.message.isNotBlank()) {
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = notification.message,
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = notification.timestamp,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
IconButton(
|
|
||||||
onClick = onDismiss,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
) {
|
|
||||||
Text("×", style = MaterialTheme.typography.titleMedium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun NotificationList(
|
|
||||||
notifications: List<Notification>,
|
|
||||||
onDismissNotification: (String) -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = modifier,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
notifications.forEach { notification ->
|
|
||||||
NotificationCard(
|
|
||||||
notification = notification,
|
|
||||||
onDismiss = { onDismissNotification(notification.id) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SnackbarNotification(
|
|
||||||
notification: Notification,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val backgroundColor = when (notification.type) {
|
|
||||||
NotificationType.SUCCESS -> Color(0xFF4CAF50)
|
|
||||||
NotificationType.ERROR -> Color(0xFFF44336)
|
|
||||||
NotificationType.WARNING -> Color(0xFFFF9800)
|
|
||||||
NotificationType.INFO -> Color(0xFF2196F3)
|
|
||||||
}
|
|
||||||
|
|
||||||
Snackbar(
|
|
||||||
modifier = modifier,
|
|
||||||
containerColor = backgroundColor,
|
|
||||||
contentColor = Color.White,
|
|
||||||
action = {
|
|
||||||
TextButton(
|
|
||||||
onClick = onDismiss,
|
|
||||||
colors = ButtonDefaults.textButtonColors(
|
|
||||||
contentColor = Color.White
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text("Dismiss")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = notification.title,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
if (notification.message.isNotBlank()) {
|
|
||||||
Text(
|
|
||||||
text = notification.message,
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ToastNotification(
|
|
||||||
message: String,
|
|
||||||
type: NotificationType = NotificationType.INFO,
|
|
||||||
visible: Boolean,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
if (visible) {
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
kotlinx.coroutines.delay(3000) // Auto dismiss after 3 seconds
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
|
|
||||||
val backgroundColor = when (type) {
|
|
||||||
NotificationType.SUCCESS -> Color(0xFF4CAF50)
|
|
||||||
NotificationType.ERROR -> Color(0xFFF44336)
|
|
||||||
NotificationType.WARNING -> Color(0xFFFF9800)
|
|
||||||
NotificationType.INFO -> Color(0xFF2196F3)
|
|
||||||
}
|
|
||||||
|
|
||||||
Card(
|
|
||||||
modifier = modifier,
|
|
||||||
colors = CardDefaults.cardColors(containerColor = backgroundColor),
|
|
||||||
shape = RoundedCornerShape(24.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = message,
|
|
||||||
color = Color.White,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
-232
@@ -1,232 +0,0 @@
|
|||||||
package at.mocode.clients.shared.commonui.layout
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import at.mocode.clients.shared.commonui.components.*
|
|
||||||
import at.mocode.clients.shared.commonui.screens.LoginScreenContainer
|
|
||||||
import at.mocode.clients.shared.presentation.state.AppState
|
|
||||||
import at.mocode.clients.shared.presentation.actions.AppAction
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun MainLayout(
|
|
||||||
appState: AppState,
|
|
||||||
onDispatchAction: (AppAction) -> Unit,
|
|
||||||
onNavigateTo: (String) -> Unit,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
var showUserMenu by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
modifier = modifier,
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = "Meldestelle",
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
// Notifications
|
|
||||||
if (appState.ui.notifications.isNotEmpty()) {
|
|
||||||
BadgedBox(
|
|
||||||
badge = {
|
|
||||||
Badge(
|
|
||||||
contentColor = MaterialTheme.colorScheme.onError,
|
|
||||||
containerColor = MaterialTheme.colorScheme.error
|
|
||||||
) {
|
|
||||||
Text(appState.ui.notifications.size.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
IconButton(
|
|
||||||
onClick = { onNavigateTo("/notifications") }
|
|
||||||
) {
|
|
||||||
Text("🔔")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
IconButton(
|
|
||||||
onClick = { onNavigateTo("/notifications") }
|
|
||||||
) {
|
|
||||||
Text("🔔")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Theme toggle
|
|
||||||
IconButton(
|
|
||||||
onClick = { onDispatchAction(AppAction.UI.ToggleDarkMode) }
|
|
||||||
) {
|
|
||||||
Text(if (appState.ui.isDarkMode) "☀️" else "🌙")
|
|
||||||
}
|
|
||||||
|
|
||||||
// User menu
|
|
||||||
Box {
|
|
||||||
IconButton(
|
|
||||||
onClick = { showUserMenu = true }
|
|
||||||
) {
|
|
||||||
Text("👤")
|
|
||||||
}
|
|
||||||
|
|
||||||
DropdownMenu(
|
|
||||||
expanded = showUserMenu,
|
|
||||||
onDismissRequest = { showUserMenu = false }
|
|
||||||
) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = appState.auth.user?.firstName ?: "User",
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = appState.auth.user?.email ?: "",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
showUserMenu = false
|
|
||||||
onNavigateTo("/profile")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Settings") },
|
|
||||||
onClick = {
|
|
||||||
showUserMenu = false
|
|
||||||
onNavigateTo("/settings")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Help") },
|
|
||||||
onClick = {
|
|
||||||
showUserMenu = false
|
|
||||||
onNavigateTo("/help")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
text = "Logout",
|
|
||||||
color = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
showUserMenu = false
|
|
||||||
onDispatchAction(AppAction.Auth.Logout)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
bottomBar = {
|
|
||||||
if (appState.ui.notifications.isNotEmpty()) {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "${appState.ui.notifications.size} notification(s)",
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
TextButton(
|
|
||||||
onClick = { onNavigateTo("/notifications") }
|
|
||||||
) {
|
|
||||||
Text("View All")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) { paddingValues ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(paddingValues)
|
|
||||||
) {
|
|
||||||
// Loading overlay
|
|
||||||
if (appState.ui.isLoading) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
FullScreenLoading("Loading...")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AuthenticatedLayout(
|
|
||||||
appState: AppState,
|
|
||||||
onDispatchAction: (AppAction) -> Unit,
|
|
||||||
onNavigateTo: (String) -> Unit,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
if (appState.auth.isAuthenticated) {
|
|
||||||
MainLayout(
|
|
||||||
appState = appState,
|
|
||||||
onDispatchAction = onDispatchAction,
|
|
||||||
onNavigateTo = onNavigateTo,
|
|
||||||
content = content,
|
|
||||||
modifier = modifier
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Show login screen if not authenticated
|
|
||||||
LoginScreenContainer(
|
|
||||||
authState = appState.auth,
|
|
||||||
onDispatchAction = onDispatchAction,
|
|
||||||
modifier = modifier
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ResponsiveLayout(
|
|
||||||
appState: AppState,
|
|
||||||
onDispatchAction: (AppAction) -> Unit,
|
|
||||||
onNavigateTo: (String) -> Unit,
|
|
||||||
content: @Composable (Boolean) -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
// Simple responsive design - could be enhanced with actual screen size detection
|
|
||||||
val isCompact = remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
AuthenticatedLayout(
|
|
||||||
appState = appState,
|
|
||||||
onDispatchAction = onDispatchAction,
|
|
||||||
onNavigateTo = onNavigateTo,
|
|
||||||
content = { content(isCompact.value) },
|
|
||||||
modifier = modifier
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-250
@@ -1,250 +0,0 @@
|
|||||||
package at.mocode.clients.shared.commonui.screens
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import at.mocode.clients.shared.commonui.components.*
|
|
||||||
import at.mocode.clients.shared.presentation.state.AppState
|
|
||||||
import at.mocode.clients.shared.presentation.actions.AppAction
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun DashboardScreen(
|
|
||||||
appState: AppState,
|
|
||||||
onDispatchAction: (AppAction) -> Unit,
|
|
||||||
onNavigateTo: (String) -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val scrollState = rememberScrollState()
|
|
||||||
val user = appState.auth.user
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.verticalScroll(scrollState)
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
// Welcome Header
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(24.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Welcome back, ${user?.firstName ?: "User"}!",
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = "Here's what's happening in your Meldestelle dashboard",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quick Actions
|
|
||||||
Text(
|
|
||||||
text = "Quick Actions",
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
PrimaryButton(
|
|
||||||
text = "New Report",
|
|
||||||
onClick = { onNavigateTo("/reports/new") },
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
SecondaryButton(
|
|
||||||
text = "View Reports",
|
|
||||||
onClick = { onNavigateTo("/reports") },
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Statistics Cards
|
|
||||||
Text(
|
|
||||||
text = "Overview",
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
StatisticCard(
|
|
||||||
title = "Total Reports",
|
|
||||||
value = "142",
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
StatisticCard(
|
|
||||||
title = "Open Issues",
|
|
||||||
value = "23",
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
StatisticCard(
|
|
||||||
title = "Resolved",
|
|
||||||
value = "119",
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
StatisticCard(
|
|
||||||
title = "This Month",
|
|
||||||
value = "18",
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recent Activity
|
|
||||||
Text(
|
|
||||||
text = "Recent Activity",
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
ActivityItem(
|
|
||||||
title = "Report #1234 updated",
|
|
||||||
subtitle = "Status changed to 'In Progress'",
|
|
||||||
timestamp = "2 hours ago"
|
|
||||||
)
|
|
||||||
HorizontalDivider()
|
|
||||||
ActivityItem(
|
|
||||||
title = "New report submitted",
|
|
||||||
subtitle = "Report #1235 - Urgent priority",
|
|
||||||
timestamp = "4 hours ago"
|
|
||||||
)
|
|
||||||
HorizontalDivider()
|
|
||||||
ActivityItem(
|
|
||||||
title = "Report #1230 resolved",
|
|
||||||
subtitle = "Issue successfully closed",
|
|
||||||
timestamp = "1 day ago"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connection Status
|
|
||||||
if (!appState.network.isOnline) {
|
|
||||||
Card(
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "⚠️",
|
|
||||||
style = MaterialTheme.typography.titleLarge
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = "Offline Mode",
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Some features may be limited",
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun StatisticCard(
|
|
||||||
title: String,
|
|
||||||
value: String,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier = modifier,
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = value,
|
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ActivityItem(
|
|
||||||
title: String,
|
|
||||||
subtitle: String,
|
|
||||||
timestamp: String,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = subtitle,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = timestamp,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-198
@@ -1,198 +0,0 @@
|
|||||||
package at.mocode.clients.shared.commonui.screens
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.focus.FocusDirection
|
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import at.mocode.clients.shared.commonui.components.*
|
|
||||||
import at.mocode.clients.shared.presentation.actions.AppAction
|
|
||||||
import at.mocode.clients.shared.presentation.state.AuthState
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun LoginScreen(
|
|
||||||
authState: AuthState,
|
|
||||||
onLoginClick: (String, String) -> Unit,
|
|
||||||
onNavigateToRegister: () -> Unit = {},
|
|
||||||
onForgotPassword: () -> Unit = {},
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
var username by remember { mutableStateOf("") }
|
|
||||||
var password by remember { mutableStateOf("") }
|
|
||||||
var usernameError by remember { mutableStateOf<String?>(null) }
|
|
||||||
var passwordError by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
val focusManager = LocalFocusManager.current
|
|
||||||
|
|
||||||
// Validate form
|
|
||||||
val isFormValid = username.isNotBlank() && password.isNotBlank() &&
|
|
||||||
usernameError == null && passwordError == null
|
|
||||||
|
|
||||||
fun validateUsername(value: String) {
|
|
||||||
usernameError = FormValidation.validateRequired(value, "Username")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun validatePassword(value: String) {
|
|
||||||
passwordError = FormValidation.validatePassword(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleLogin() {
|
|
||||||
validateUsername(username)
|
|
||||||
validatePassword(password)
|
|
||||||
|
|
||||||
if (isFormValid) {
|
|
||||||
onLoginClick(username.trim(), password)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(24.dp),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(32.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
// Header
|
|
||||||
Text(
|
|
||||||
text = "Meldestelle",
|
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "Sign in to your account",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
// Username Field
|
|
||||||
MeldestelleTextField(
|
|
||||||
value = username,
|
|
||||||
onValueChange = {
|
|
||||||
username = it
|
|
||||||
if (usernameError != null) validateUsername(it)
|
|
||||||
},
|
|
||||||
label = "Username",
|
|
||||||
placeholder = "Enter your username",
|
|
||||||
isError = usernameError != null,
|
|
||||||
errorMessage = usernameError,
|
|
||||||
enabled = !authState.isLoading,
|
|
||||||
imeAction = ImeAction.Next,
|
|
||||||
keyboardActions = KeyboardActions(
|
|
||||||
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Password Field
|
|
||||||
MeldestellePasswordField(
|
|
||||||
value = password,
|
|
||||||
onValueChange = {
|
|
||||||
password = it
|
|
||||||
if (passwordError != null) validatePassword(it)
|
|
||||||
},
|
|
||||||
label = "Password",
|
|
||||||
placeholder = "Enter your password",
|
|
||||||
isError = passwordError != null,
|
|
||||||
errorMessage = passwordError,
|
|
||||||
enabled = !authState.isLoading,
|
|
||||||
imeAction = ImeAction.Done,
|
|
||||||
keyboardActions = KeyboardActions(
|
|
||||||
onDone = {
|
|
||||||
focusManager.clearFocus()
|
|
||||||
if (isFormValid) handleLogin()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Error display
|
|
||||||
authState.error?.let { errorMessage ->
|
|
||||||
Text(
|
|
||||||
text = errorMessage,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
// Login Button
|
|
||||||
PrimaryButton(
|
|
||||||
text = "Sign In",
|
|
||||||
onClick = ::handleLogin,
|
|
||||||
enabled = isFormValid && !authState.isLoading,
|
|
||||||
isLoading = authState.isLoading,
|
|
||||||
fullWidth = true
|
|
||||||
)
|
|
||||||
|
|
||||||
// Forgot Password
|
|
||||||
TextButton(
|
|
||||||
onClick = onForgotPassword,
|
|
||||||
enabled = !authState.isLoading
|
|
||||||
) {
|
|
||||||
Text("Forgot Password?")
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
// Register Link
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Don't have an account?",
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
TextButton(
|
|
||||||
onClick = onNavigateToRegister,
|
|
||||||
enabled = !authState.isLoading
|
|
||||||
) {
|
|
||||||
Text("Sign Up")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LoginScreenContainer(
|
|
||||||
authState: AuthState,
|
|
||||||
onDispatchAction: (AppAction) -> Unit,
|
|
||||||
onNavigateToRegister: () -> Unit = {},
|
|
||||||
onForgotPassword: () -> Unit = {},
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
LoginScreen(
|
|
||||||
authState = authState,
|
|
||||||
onLoginClick = { username, password ->
|
|
||||||
onDispatchAction(AppAction.Auth.LoginStart(username, password))
|
|
||||||
},
|
|
||||||
onNavigateToRegister = onNavigateToRegister,
|
|
||||||
onForgotPassword = onForgotPassword,
|
|
||||||
modifier = modifier
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -18,12 +18,14 @@ kotlin {
|
|||||||
|
|
||||||
js {
|
js {
|
||||||
browser()
|
browser()
|
||||||
|
binaries.executable()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enableWasm) {
|
if (enableWasm) {
|
||||||
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
||||||
wasmJs {
|
wasmJs {
|
||||||
browser()
|
browser()
|
||||||
|
binaries.executable()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
@@ -5,4 +5,5 @@ sealed class AppScreen {
|
|||||||
data object Login : AppScreen()
|
data object Login : AppScreen()
|
||||||
data object Ping : AppScreen()
|
data object Ping : AppScreen()
|
||||||
data object Profile : AppScreen()
|
data object Profile : AppScreen()
|
||||||
|
data object AuthCallback : AppScreen()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
package at.mocode.clients.shared
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Zentrale App-Konfiguration für alle Client-Module.
|
|
||||||
* Hinweis: Diese Werte sind zentrale Defaults für DEV. Für PROD sollten sie
|
|
||||||
* via Build-Injektion (Gradle/ENV) überschrieben werden. Ein einfaches
|
|
||||||
* BuildConfig-Setup kann später ergänzt werden.
|
|
||||||
*/
|
|
||||||
object AppConfig {
|
|
||||||
// Gateway Basis-URL (API Gateway)
|
|
||||||
const val GATEWAY_URL: String = "http://localhost:8081"
|
|
||||||
|
|
||||||
// Keycloak Konfiguration
|
|
||||||
const val KEYCLOAK_URL: String = "http://localhost:8180"
|
|
||||||
const val KEYCLOAK_REALM: String = "meldestelle"
|
|
||||||
const val KEYCLOAK_CLIENT_ID: String = "meldestelle-frontend"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package at.mocode.clients.shared.core
|
||||||
|
|
||||||
|
data class AppConfig(
|
||||||
|
val gatewayUrl: String,
|
||||||
|
val isDebug: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
// Standard-Config für Local Development
|
||||||
|
val devConfig = AppConfig(
|
||||||
|
gatewayUrl = "http://localhost:8081",
|
||||||
|
isDebug = true
|
||||||
|
)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package at.mocode.clients.shared.core
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared application configuration constants for clients.
|
||||||
|
* These defaults target local development environments.
|
||||||
|
*/
|
||||||
|
object AppConstants {
|
||||||
|
// Gateway base URL (reverse proxy / API gateway)
|
||||||
|
const val GATEWAY_URL: String = "http://localhost:8081"
|
||||||
|
|
||||||
|
// Keycloak configuration
|
||||||
|
const val KEYCLOAK_URL: String = "http://localhost:8180"
|
||||||
|
const val KEYCLOAK_REALM: String = "meldestelle"
|
||||||
|
|
||||||
|
// Use public client configured in realm import: `web-app`
|
||||||
|
const val KEYCLOAK_CLIENT_ID: String = "web-app"
|
||||||
|
|
||||||
|
// Default redirect URI for web PKCE flow (served by Nginx in web image)
|
||||||
|
// We use the root path so Keycloak can redirect back to /?code=...
|
||||||
|
fun webRedirectUri(): String = "http://localhost:4000/"
|
||||||
|
|
||||||
|
fun registerUrl(): String =
|
||||||
|
"$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/registrations?client_id=$KEYCLOAK_CLIENT_ID&response_type=code&redirect_uri=${
|
||||||
|
encode(
|
||||||
|
webRedirectUri()
|
||||||
|
)
|
||||||
|
}"
|
||||||
|
|
||||||
|
fun loginUrl(): String =
|
||||||
|
"$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/auth?client_id=$KEYCLOAK_CLIENT_ID&response_type=code&redirect_uri=${
|
||||||
|
encode(
|
||||||
|
webRedirectUri()
|
||||||
|
)
|
||||||
|
}"
|
||||||
|
|
||||||
|
fun authorizeEndpoint(): String =
|
||||||
|
"$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/auth"
|
||||||
|
|
||||||
|
fun tokenEndpoint(): String =
|
||||||
|
"$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/token"
|
||||||
|
|
||||||
|
fun desktopDownloadUrl(): String = "http://localhost:4000/downloads/"
|
||||||
|
|
||||||
|
// Helper to URL-encode values (very small percent-encoding sufficient for URIs here)
|
||||||
|
private fun encode(value: String): String =
|
||||||
|
value.replace("://", ":%2F%2F").replace("/", "%2F").replace(":", "%3A")
|
||||||
|
}
|
||||||
-171
@@ -1,171 +0,0 @@
|
|||||||
package at.mocode.clients.shared.data.repository
|
|
||||||
|
|
||||||
import at.mocode.clients.shared.domain.models.User
|
|
||||||
import at.mocode.clients.shared.domain.models.AuthToken
|
|
||||||
import at.mocode.clients.shared.domain.models.ApiResponse
|
|
||||||
import at.mocode.clients.shared.network.HttpClientConfig
|
|
||||||
import io.ktor.client.*
|
|
||||||
import io.ktor.client.call.*
|
|
||||||
import io.ktor.client.request.*
|
|
||||||
import io.ktor.client.request.forms.*
|
|
||||||
import io.ktor.http.*
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authentication repository handling all authentication-related operations
|
|
||||||
* with Keycloak integration.
|
|
||||||
*/
|
|
||||||
class AuthRepository(
|
|
||||||
private val baseUrl: String = "http://localhost:8080",
|
|
||||||
private val keycloakUrl: String = "http://localhost:8180",
|
|
||||||
private val realm: String = "meldestelle",
|
|
||||||
private val clientId: String = "meldestelle-client"
|
|
||||||
) : Repository {
|
|
||||||
|
|
||||||
private val httpClient: HttpClient = HttpClientConfig.createClient(baseUrl)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class LoginRequest(
|
|
||||||
val username: String,
|
|
||||||
val password: String
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class KeycloakTokenResponse(
|
|
||||||
val access_token: String,
|
|
||||||
val refresh_token: String,
|
|
||||||
val expires_in: Long,
|
|
||||||
val token_type: String = "Bearer"
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authenticate user with username and password via Keycloak
|
|
||||||
*/
|
|
||||||
suspend fun login(username: String, password: String): RepositoryResult<AuthToken> {
|
|
||||||
return try {
|
|
||||||
val response = httpClient.submitForm(
|
|
||||||
url = "$keycloakUrl/realms/$realm/protocol/openid-connect/token",
|
|
||||||
formParameters = Parameters.build {
|
|
||||||
append("grant_type", "password")
|
|
||||||
append("client_id", clientId)
|
|
||||||
append("username", username)
|
|
||||||
append("password", password)
|
|
||||||
}
|
|
||||||
).body<KeycloakTokenResponse>()
|
|
||||||
|
|
||||||
val authToken = AuthToken(
|
|
||||||
accessToken = response.access_token,
|
|
||||||
refreshToken = response.refresh_token,
|
|
||||||
expiresIn = response.expires_in,
|
|
||||||
tokenType = response.token_type
|
|
||||||
)
|
|
||||||
|
|
||||||
RepositoryResult.Success(authToken)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
RepositoryResult.Error(
|
|
||||||
at.mocode.clients.shared.domain.models.ApiError(
|
|
||||||
code = "LOGIN_FAILED",
|
|
||||||
message = "Login failed: ${e.message}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh authentication token
|
|
||||||
*/
|
|
||||||
suspend fun refreshToken(refreshToken: String): RepositoryResult<AuthToken> {
|
|
||||||
return try {
|
|
||||||
val response = httpClient.submitForm(
|
|
||||||
url = "$keycloakUrl/realms/$realm/protocol/openid-connect/token",
|
|
||||||
formParameters = Parameters.build {
|
|
||||||
append("grant_type", "refresh_token")
|
|
||||||
append("client_id", clientId)
|
|
||||||
append("refresh_token", refreshToken)
|
|
||||||
}
|
|
||||||
).body<KeycloakTokenResponse>()
|
|
||||||
|
|
||||||
val authToken = AuthToken(
|
|
||||||
accessToken = response.access_token,
|
|
||||||
refreshToken = response.refresh_token,
|
|
||||||
expiresIn = response.expires_in,
|
|
||||||
tokenType = response.token_type
|
|
||||||
)
|
|
||||||
|
|
||||||
RepositoryResult.Success(authToken)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
RepositoryResult.Error(
|
|
||||||
at.mocode.clients.shared.domain.models.ApiError(
|
|
||||||
code = "TOKEN_REFRESH_FAILED",
|
|
||||||
message = "Token refresh failed: ${e.message}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current user information using access token
|
|
||||||
*/
|
|
||||||
suspend fun getCurrentUser(accessToken: String): RepositoryResult<User> {
|
|
||||||
return try {
|
|
||||||
val response = httpClient.get("$baseUrl/api/auth/me") {
|
|
||||||
header("Authorization", "Bearer $accessToken")
|
|
||||||
}.body<ApiResponse<User>>()
|
|
||||||
|
|
||||||
response.toRepositoryResult()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
RepositoryResult.Error(
|
|
||||||
at.mocode.clients.shared.domain.models.ApiError(
|
|
||||||
code = "USER_INFO_FAILED",
|
|
||||||
message = "Failed to get user info: ${e.message}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logout user by invalidating tokens
|
|
||||||
*/
|
|
||||||
suspend fun logout(refreshToken: String): RepositoryResult<Unit> {
|
|
||||||
return try {
|
|
||||||
httpClient.submitForm(
|
|
||||||
url = "$keycloakUrl/realms/$realm/protocol/openid-connect/logout",
|
|
||||||
formParameters = Parameters.build {
|
|
||||||
append("client_id", clientId)
|
|
||||||
append("refresh_token", refreshToken)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
RepositoryResult.Success(Unit)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
RepositoryResult.Error(
|
|
||||||
at.mocode.clients.shared.domain.models.ApiError(
|
|
||||||
code = "LOGOUT_FAILED",
|
|
||||||
message = "Logout failed: ${e.message}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if token is still valid
|
|
||||||
*/
|
|
||||||
suspend fun validateToken(accessToken: String): RepositoryResult<Boolean> {
|
|
||||||
return try {
|
|
||||||
val response = httpClient.get("$baseUrl/api/auth/validate") {
|
|
||||||
header("Authorization", "Bearer $accessToken")
|
|
||||||
}.body<ApiResponse<Boolean>>()
|
|
||||||
|
|
||||||
response.toRepositoryResult()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
RepositoryResult.Success(false) // Token is invalid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleanup resources
|
|
||||||
*/
|
|
||||||
fun close() {
|
|
||||||
httpClient.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package at.mocode.clients.shared.data.repository
|
||||||
|
|
||||||
|
import at.mocode.clients.shared.domain.model.PingData
|
||||||
|
import at.mocode.clients.shared.domain.model.Resource
|
||||||
|
import at.mocode.clients.shared.domain.repository.PingRepository
|
||||||
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.call.*
|
||||||
|
import io.ktor.client.request.*
|
||||||
|
|
||||||
|
class PingRepositoryImpl(
|
||||||
|
private val httpClient: HttpClient
|
||||||
|
) : PingRepository {
|
||||||
|
|
||||||
|
override suspend fun checkSystemStatus(): Resource<PingData> {
|
||||||
|
return try {
|
||||||
|
// Der HttpClient hat die BaseURL schon konfiguriert (siehe NetworkModule)
|
||||||
|
val response = httpClient.get("/api/ping/simple").body<PingData>()
|
||||||
|
Resource.Success(response)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Hier fangen wir Netzwerkfehler ab und machen sie "hübsch" für die UI
|
||||||
|
Resource.Error(
|
||||||
|
message = "Verbindung fehlgeschlagen: ${e.message ?: "Unbekannter Fehler"}",
|
||||||
|
code = "NETWORK_ERROR"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
-73
@@ -1,73 +0,0 @@
|
|||||||
package at.mocode.clients.shared.data.repository
|
|
||||||
|
|
||||||
import at.mocode.clients.shared.domain.models.ApiResponse
|
|
||||||
import at.mocode.clients.shared.domain.models.ApiError
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base repository interface defining common operations and patterns
|
|
||||||
* for data access across the application.
|
|
||||||
*/
|
|
||||||
interface Repository
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result wrapper for repository operations to handle success/error states
|
|
||||||
*/
|
|
||||||
sealed class RepositoryResult<out T> {
|
|
||||||
data class Success<T>(val data: T) : RepositoryResult<T>()
|
|
||||||
data class Error(val error: ApiError) : RepositoryResult<Nothing>()
|
|
||||||
data class Loading(val message: String = "Loading...") : RepositoryResult<Nothing>()
|
|
||||||
|
|
||||||
fun isSuccess(): Boolean = this is Success
|
|
||||||
fun isError(): Boolean = this is Error
|
|
||||||
fun isLoading(): Boolean = this is Loading
|
|
||||||
|
|
||||||
fun getOrNull(): T? = when (this) {
|
|
||||||
is Success -> data
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getErrorOrNull(): ApiError? = when (this) {
|
|
||||||
is Error -> error
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extension function to convert ApiResponse to RepositoryResult
|
|
||||||
*/
|
|
||||||
fun <T> ApiResponse<T>.toRepositoryResult(): RepositoryResult<T> {
|
|
||||||
return if (success && data != null) {
|
|
||||||
RepositoryResult.Success(data)
|
|
||||||
} else {
|
|
||||||
RepositoryResult.Error(
|
|
||||||
error ?: ApiError(
|
|
||||||
code = "UNKNOWN_ERROR",
|
|
||||||
message = "Unknown error occurred"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extension function to handle repository results with callbacks
|
|
||||||
*/
|
|
||||||
inline fun <T> RepositoryResult<T>.onSuccess(action: (T) -> Unit): RepositoryResult<T> {
|
|
||||||
if (this is RepositoryResult.Success) {
|
|
||||||
action(data)
|
|
||||||
}
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
inline fun <T> RepositoryResult<T>.onError(action: (ApiError) -> Unit): RepositoryResult<T> {
|
|
||||||
if (this is RepositoryResult.Error) {
|
|
||||||
action(error)
|
|
||||||
}
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
inline fun <T> RepositoryResult<T>.onLoading(action: (String) -> Unit): RepositoryResult<T> {
|
|
||||||
if (this is RepositoryResult.Loading) {
|
|
||||||
action(message)
|
|
||||||
}
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package at.mocode.clients.shared.di
|
||||||
|
|
||||||
|
import at.mocode.clients.shared.core.AppConfig
|
||||||
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.plugins.*
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.*
|
||||||
|
import io.ktor.client.plugins.logging.*
|
||||||
|
import io.ktor.http.*
|
||||||
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
val networkModule = module {
|
||||||
|
// 1. JSON Konfiguration (Global verfügbar)
|
||||||
|
single {
|
||||||
|
Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
prettyPrint = true
|
||||||
|
isLenient = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. HttpClient (Singleton)
|
||||||
|
single {
|
||||||
|
val config = get<AppConfig>()
|
||||||
|
val jsonConfig = get<Json>()
|
||||||
|
|
||||||
|
HttpClient {
|
||||||
|
// Standard-URL setzen
|
||||||
|
defaultRequest {
|
||||||
|
url(config.gatewayUrl)
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(jsonConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
install(Logging) {
|
||||||
|
level = if (config.isDebug) LogLevel.INFO else LogLevel.NONE
|
||||||
|
logger = Logger.DEFAULT
|
||||||
|
}
|
||||||
|
|
||||||
|
install(HttpTimeout) {
|
||||||
|
requestTimeoutMillis = 10000
|
||||||
|
connectTimeoutMillis = 10000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package at.mocode.clients.shared.di
|
||||||
|
|
||||||
|
import at.mocode.clients.shared.core.devConfig
|
||||||
|
import org.koin.core.context.startKoin
|
||||||
|
import org.koin.dsl.KoinAppDeclaration
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
// Das Modul für die Config
|
||||||
|
val configModule = module {
|
||||||
|
single { devConfig } // Später können wir hier PROD/DEV umschalten
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle Module zusammen
|
||||||
|
val sharedModules = listOf(
|
||||||
|
configModule,
|
||||||
|
networkModule
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper zum Starten von Koin (wird von der App aufgerufen)
|
||||||
|
fun initKoin(appDeclaration: KoinAppDeclaration = {}) = startKoin {
|
||||||
|
appDeclaration()
|
||||||
|
modules(sharedModules)
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
package at.mocode.clients.shared.domain.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generischer Wrapper für API-Antworten.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ApiResponse<T>(
|
||||||
|
val success: Boolean,
|
||||||
|
val data: T? = null,
|
||||||
|
val error: ApiError? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ApiError(
|
||||||
|
val code: String,
|
||||||
|
val message: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Ergebnis eines Repository-Aufrufs.
|
||||||
|
* Die UI kennt nur das hier, keine HTTP-Exceptions!
|
||||||
|
*/
|
||||||
|
sealed class Resource<out T> {
|
||||||
|
data class Success<T>(val data: T) : Resource<T>()
|
||||||
|
data class Error(val message: String, val code: String? = null) : Resource<Nothing>()
|
||||||
|
data object Loading : Resource<Nothing>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datenmodell für den Ping.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PingData(
|
||||||
|
val status: String,
|
||||||
|
val timestamp: String,
|
||||||
|
val service: String
|
||||||
|
)
|
||||||
-27
@@ -1,27 +0,0 @@
|
|||||||
package at.mocode.clients.shared.domain.models
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ApiResponse<T>(
|
|
||||||
val success: Boolean,
|
|
||||||
val data: T? = null,
|
|
||||||
val error: ApiError? = null,
|
|
||||||
val timestamp: String,
|
|
||||||
val correlationId: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ApiError(
|
|
||||||
val code: String,
|
|
||||||
val message: String,
|
|
||||||
val details: Map<String, String> = emptyMap()
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class HealthResponse(
|
|
||||||
val status: String,
|
|
||||||
val timestamp: String,
|
|
||||||
val service: String,
|
|
||||||
val healthy: Boolean
|
|
||||||
)
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package at.mocode.clients.shared.domain.models
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class User(
|
|
||||||
val id: String,
|
|
||||||
val username: String,
|
|
||||||
val email: String,
|
|
||||||
val firstName: String,
|
|
||||||
val lastName: String,
|
|
||||||
val roles: Set<String> = emptySet(),
|
|
||||||
val isActive: Boolean = true
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class AuthToken(
|
|
||||||
val accessToken: String,
|
|
||||||
val refreshToken: String,
|
|
||||||
val expiresIn: Long,
|
|
||||||
val tokenType: String = "Bearer"
|
|
||||||
)
|
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package at.mocode.clients.shared.domain.repository
|
||||||
|
|
||||||
|
import at.mocode.clients.shared.domain.model.PingData
|
||||||
|
import at.mocode.clients.shared.domain.model.Resource
|
||||||
|
|
||||||
|
interface PingRepository {
|
||||||
|
suspend fun checkSystemStatus(): Resource<PingData>
|
||||||
|
}
|
||||||
-194
@@ -1,194 +0,0 @@
|
|||||||
package at.mocode.clients.shared.navigation
|
|
||||||
|
|
||||||
import at.mocode.clients.shared.presentation.store.AppStore
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deep link handling for the application
|
|
||||||
*/
|
|
||||||
class DeepLinkHandler(
|
|
||||||
private val navigationManager: NavigationManager,
|
|
||||||
private val store: AppStore
|
|
||||||
) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deep link configuration
|
|
||||||
*/
|
|
||||||
data class DeepLinkConfig(
|
|
||||||
val scheme: String = "meldestelle",
|
|
||||||
val host: String = "app",
|
|
||||||
val allowedDomains: Set<String> = setOf("meldestelle.com", "localhost")
|
|
||||||
)
|
|
||||||
|
|
||||||
private val config = DeepLinkConfig()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle a deep link URL
|
|
||||||
*/
|
|
||||||
fun handleDeepLink(url: String): Boolean {
|
|
||||||
return try {
|
|
||||||
val parsedLink = parseDeepLink(url)
|
|
||||||
if (parsedLink != null) {
|
|
||||||
processDeepLink(parsedLink)
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Log error in real implementation
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse deep link URL into components
|
|
||||||
*/
|
|
||||||
private fun parseDeepLink(url: String): DeepLink? {
|
|
||||||
return when {
|
|
||||||
url.startsWith("${config.scheme}://") -> parseCustomSchemeLink(url)
|
|
||||||
url.startsWith("https://") || url.startsWith("http://") -> parseWebLink(url)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse custom scheme deep links (e.g., meldestelle://app/dashboard)
|
|
||||||
*/
|
|
||||||
private fun parseCustomSchemeLink(url: String): DeepLink? {
|
|
||||||
val withoutScheme = url.removePrefix("${config.scheme}://")
|
|
||||||
val parts = withoutScheme.split("/")
|
|
||||||
|
|
||||||
if (parts.isEmpty() || parts[0] != config.host) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
val path = "/" + parts.drop(1).joinToString("/")
|
|
||||||
val route = if (path == "/") Routes.HOME else path
|
|
||||||
|
|
||||||
return DeepLink(
|
|
||||||
type = DeepLinkType.CUSTOM_SCHEME,
|
|
||||||
route = route,
|
|
||||||
params = RouteUtils.parseRouteParams(route),
|
|
||||||
originalUrl = url
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse web deep links (e.g., https://meldestelle.com/dashboard)
|
|
||||||
*/
|
|
||||||
private fun parseWebLink(url: String): DeepLink? {
|
|
||||||
// Simple URL parsing - in real implementation use proper URL parser
|
|
||||||
val urlParts = url.split("/")
|
|
||||||
if (urlParts.size < 3) return null
|
|
||||||
|
|
||||||
val domain = urlParts[2]
|
|
||||||
if (!config.allowedDomains.contains(domain)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
val path = "/" + urlParts.drop(3).joinToString("/")
|
|
||||||
val route = if (path == "/" || path.isEmpty()) Routes.HOME else path
|
|
||||||
|
|
||||||
return DeepLink(
|
|
||||||
type = DeepLinkType.WEB_LINK,
|
|
||||||
route = route,
|
|
||||||
params = RouteUtils.parseRouteParams(route),
|
|
||||||
originalUrl = url
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process a parsed deep link
|
|
||||||
*/
|
|
||||||
private fun processDeepLink(deepLink: DeepLink) {
|
|
||||||
val authState = store.state.value.auth
|
|
||||||
val cleanRoute = RouteUtils.getCleanRoute(deepLink.route)
|
|
||||||
|
|
||||||
// Check if route requires authentication
|
|
||||||
if (RouteUtils.requiresAuth(cleanRoute)) {
|
|
||||||
if (!authState.isAuthenticated) {
|
|
||||||
// Save the intended route and redirect to log in
|
|
||||||
saveIntendedRoute(deepLink.route)
|
|
||||||
navigationManager.navigateTo(Routes.Auth.LOGIN)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if route requires admin privileges
|
|
||||||
if (RouteUtils.requiresAdmin(cleanRoute)) {
|
|
||||||
val hasAdminRole = authState.user?.roles?.contains("admin") ?: false
|
|
||||||
if (!hasAdminRole) {
|
|
||||||
// Redirect to unauthorized or home
|
|
||||||
navigationManager.navigateTo(Routes.HOME)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate to the route
|
|
||||||
navigationManager.navigateTo(deepLink.route)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save the intended route for after authentication
|
|
||||||
*/
|
|
||||||
private fun saveIntendedRoute(route: String) {
|
|
||||||
// In real implementation, save to persistent storage
|
|
||||||
// For now; we'll store it in a simple variable
|
|
||||||
intendedRoute = route
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get and clear the intended route
|
|
||||||
*/
|
|
||||||
fun getAndClearIntendedRoute(): String? {
|
|
||||||
val route = intendedRoute
|
|
||||||
intendedRoute = null
|
|
||||||
return route
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if there's a pending intended route
|
|
||||||
*/
|
|
||||||
fun hasIntendedRoute(): Boolean = intendedRoute != null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a deep link for a route
|
|
||||||
*/
|
|
||||||
fun generateDeepLink(route: String, useCustomScheme: Boolean = true): String {
|
|
||||||
return if (useCustomScheme) {
|
|
||||||
"${config.scheme}://${config.host}$route"
|
|
||||||
} else {
|
|
||||||
"https://${config.allowedDomains.first()}$route"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate if a route is valid for deep linking
|
|
||||||
*/
|
|
||||||
fun isValidDeepLinkRoute(route: String): Boolean {
|
|
||||||
return RouteUtils.isValidRoute(route) &&
|
|
||||||
!route.startsWith("/auth/") && // Auth routes shouldn't be deep linked
|
|
||||||
route != Routes.Auth.LOGIN
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private var intendedRoute: String? = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deep link data class
|
|
||||||
*/
|
|
||||||
data class DeepLink(
|
|
||||||
val type: DeepLinkType,
|
|
||||||
val route: String,
|
|
||||||
val params: Map<String, String>,
|
|
||||||
val originalUrl: String
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Types of deep links
|
|
||||||
*/
|
|
||||||
enum class DeepLinkType {
|
|
||||||
CUSTOM_SCHEME, // meldestelle://app/route
|
|
||||||
WEB_LINK // https://meldestelle.com/route
|
|
||||||
}
|
|
||||||
-179
@@ -1,179 +0,0 @@
|
|||||||
package at.mocode.clients.shared.navigation
|
|
||||||
|
|
||||||
import at.mocode.clients.shared.presentation.actions.AppAction
|
|
||||||
import at.mocode.clients.shared.presentation.store.AppStore
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigation manager for handling routing and navigation logic
|
|
||||||
*/
|
|
||||||
class NavigationManager(
|
|
||||||
private val store: AppStore
|
|
||||||
) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Current route as a flow
|
|
||||||
*/
|
|
||||||
val currentRoute: Flow<String> = store.state.map { it.navigation.currentRoute }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigation history as a flow
|
|
||||||
*/
|
|
||||||
val navigationHistory: Flow<List<String>> = store.state.map { it.navigation.history }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Can go back flag as a flow
|
|
||||||
*/
|
|
||||||
val canGoBack: Flow<Boolean> = store.state.map { it.navigation.canGoBack }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate to a specific route
|
|
||||||
*/
|
|
||||||
fun navigateTo(route: String) {
|
|
||||||
store.dispatch(AppAction.Navigation.NavigateTo(route))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate back to the previous route
|
|
||||||
*/
|
|
||||||
fun navigateBack() {
|
|
||||||
store.dispatch(AppAction.Navigation.NavigateBack)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace current route without adding to history
|
|
||||||
*/
|
|
||||||
fun replaceRoute(route: String) {
|
|
||||||
store.dispatch(AppAction.Navigation.UpdateHistory(route))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear navigation history and navigate to the route
|
|
||||||
*/
|
|
||||||
fun navigateAndClearHistory(route: String) {
|
|
||||||
// First clear by replacing with the new route
|
|
||||||
store.dispatch(AppAction.Navigation.UpdateHistory(route))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current route value (non-reactive)
|
|
||||||
*/
|
|
||||||
fun getCurrentRoute(): String = store.state.value.navigation.currentRoute
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if we can navigate back
|
|
||||||
*/
|
|
||||||
fun canNavigateBack(): Boolean = store.state.value.navigation.canGoBack
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Route definitions for the application
|
|
||||||
*/
|
|
||||||
object Routes {
|
|
||||||
const val HOME = "/"
|
|
||||||
const val LOGIN = "/login"
|
|
||||||
const val DASHBOARD = "/dashboard"
|
|
||||||
const val PROFILE = "/profile"
|
|
||||||
const val SETTINGS = "/settings"
|
|
||||||
const val PING = "/ping"
|
|
||||||
|
|
||||||
// Auth-related routes
|
|
||||||
object Auth {
|
|
||||||
const val LOGIN = "/auth/login"
|
|
||||||
const val LOGOUT = "/auth/logout"
|
|
||||||
const val REGISTER = "/auth/register"
|
|
||||||
const val FORGOT_PASSWORD = "/auth/forgot-password"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin routes
|
|
||||||
object Admin {
|
|
||||||
const val DASHBOARD = "/admin/dashboard"
|
|
||||||
const val USERS = "/admin/users"
|
|
||||||
const val SETTINGS = "/admin/settings"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Feature routes
|
|
||||||
object Features {
|
|
||||||
const val PING = "/features/ping"
|
|
||||||
const val REPORTS = "/features/reports"
|
|
||||||
const val NOTIFICATIONS = "/features/notifications"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Route validation and utilities
|
|
||||||
*/
|
|
||||||
object RouteUtils {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a route requires authentication
|
|
||||||
*/
|
|
||||||
fun requiresAuth(route: String): Boolean {
|
|
||||||
return when {
|
|
||||||
route.startsWith("/auth/") && route != Routes.Auth.LOGIN -> false
|
|
||||||
route == Routes.HOME -> false
|
|
||||||
route == Routes.LOGIN -> false
|
|
||||||
else -> true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a route is for admin only
|
|
||||||
*/
|
|
||||||
fun requiresAdmin(route: String): Boolean {
|
|
||||||
return route.startsWith("/admin/")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the default route for authenticated users
|
|
||||||
*/
|
|
||||||
fun getDefaultAuthenticatedRoute(): String = Routes.DASHBOARD
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the default route for unauthenticated users
|
|
||||||
*/
|
|
||||||
fun getDefaultUnauthenticatedRoute(): String = Routes.LOGIN
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate route format
|
|
||||||
*/
|
|
||||||
fun isValidRoute(route: String): Boolean {
|
|
||||||
return route.startsWith("/") && route.isNotBlank()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse route parameters (simple implementation)
|
|
||||||
*/
|
|
||||||
fun parseRouteParams(route: String): Map<String, String> {
|
|
||||||
val params = mutableMapOf<String, String>()
|
|
||||||
|
|
||||||
// Simple query parameter parsing
|
|
||||||
if (route.contains("?")) {
|
|
||||||
val parts = route.split("?")
|
|
||||||
if (parts.size == 2) {
|
|
||||||
val queryParams = parts[1].split("&")
|
|
||||||
queryParams.forEach { param ->
|
|
||||||
val keyValue = param.split("=")
|
|
||||||
if (keyValue.size == 2) {
|
|
||||||
params[keyValue[0]] = keyValue[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get clean route without parameters
|
|
||||||
*/
|
|
||||||
fun getCleanRoute(route: String): String {
|
|
||||||
return if (route.contains("?")) {
|
|
||||||
route.split("?")[0]
|
|
||||||
} else {
|
|
||||||
route
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-74
@@ -1,74 +0,0 @@
|
|||||||
package at.mocode.clients.shared.navigation
|
|
||||||
|
|
||||||
import at.mocode.clients.shared.presentation.state.NavigationState
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface für das Persistieren von Navigation State
|
|
||||||
*/
|
|
||||||
interface NavigationPersistence {
|
|
||||||
suspend fun saveNavigationState(state: NavigationState)
|
|
||||||
fun getNavigationState(): Flow<NavigationState?>
|
|
||||||
suspend fun clearNavigationState()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default implementation ohne echte Persistierung (In-Memory)
|
|
||||||
* Platform-spezifische Implementierungen können echte Persistierung bereitstellen
|
|
||||||
*/
|
|
||||||
class DefaultNavigationPersistence : NavigationPersistence {
|
|
||||||
private var currentState: NavigationState? = null
|
|
||||||
|
|
||||||
override suspend fun saveNavigationState(state: NavigationState) {
|
|
||||||
currentState = state
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getNavigationState(): Flow<NavigationState?> {
|
|
||||||
return kotlinx.coroutines.flow.flowOf(currentState)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun clearNavigationState() {
|
|
||||||
currentState = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigation History Manager mit Persistierung
|
|
||||||
*/
|
|
||||||
class NavigationHistoryManager(
|
|
||||||
private val persistence: NavigationPersistence
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
private const val MAX_HISTORY_SIZE = 50
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun saveRoute(route: String, history: List<String>) {
|
|
||||||
val state = NavigationState(
|
|
||||||
currentRoute = route,
|
|
||||||
history = history.takeLast(MAX_HISTORY_SIZE),
|
|
||||||
canGoBack = history.isNotEmpty()
|
|
||||||
)
|
|
||||||
persistence.saveNavigationState(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getPersistedState() = persistence.getNavigationState()
|
|
||||||
|
|
||||||
suspend fun clear() = persistence.clearNavigationState()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optimiert die History für bessere Performance
|
|
||||||
*/
|
|
||||||
private fun optimizeHistory(history: List<String>): List<String> {
|
|
||||||
// Entfernt Duplikate in Folge und behält nur die letzten N Einträge
|
|
||||||
return history
|
|
||||||
.fold(emptyList<String>()) { acc, route ->
|
|
||||||
if (acc.lastOrNull() != route) acc + route else acc
|
|
||||||
}
|
|
||||||
.takeLast(MAX_HISTORY_SIZE)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun addToHistory(newRoute: String, currentHistory: List<String>) {
|
|
||||||
val optimizedHistory = optimizeHistory(currentHistory + newRoute)
|
|
||||||
saveRoute(newRoute, optimizedHistory.dropLast(1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-27
@@ -1,27 +0,0 @@
|
|||||||
package at.mocode.clients.shared.network
|
|
||||||
|
|
||||||
import io.ktor.client.*
|
|
||||||
import io.ktor.client.plugins.contentnegotiation.*
|
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
|
|
||||||
object HttpClientConfig {
|
|
||||||
|
|
||||||
fun createClient(
|
|
||||||
baseUrl: String = "http://localhost:8080"
|
|
||||||
): HttpClient = HttpClient {
|
|
||||||
|
|
||||||
// Content negotiation with JSON (based on PingApiClient pattern)
|
|
||||||
install(ContentNegotiation) {
|
|
||||||
json(Json {
|
|
||||||
prettyPrint = true
|
|
||||||
isLenient = true
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createClientWithBaseUrl(baseUrl: String): HttpClient {
|
|
||||||
return createClient(baseUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-164
@@ -1,164 +0,0 @@
|
|||||||
package at.mocode.clients.shared.network
|
|
||||||
|
|
||||||
import at.mocode.clients.shared.domain.models.ApiError
|
|
||||||
import io.ktor.client.network.sockets.*
|
|
||||||
import io.ktor.client.plugins.*
|
|
||||||
import kotlinx.io.IOException
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom exceptions for network operations
|
|
||||||
*/
|
|
||||||
sealed class NetworkException(
|
|
||||||
message: String,
|
|
||||||
cause: Throwable? = null,
|
|
||||||
val apiError: ApiError
|
|
||||||
) : Exception(message, cause) {
|
|
||||||
|
|
||||||
class ConnectionException(
|
|
||||||
message: String = "Connection failed",
|
|
||||||
cause: Throwable? = null
|
|
||||||
) : NetworkException(
|
|
||||||
message = message,
|
|
||||||
cause = cause,
|
|
||||||
apiError = ApiError(
|
|
||||||
code = "CONNECTION_ERROR",
|
|
||||||
message = message,
|
|
||||||
details = mapOf("type" to "network_connectivity")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
class TimeoutException(
|
|
||||||
message: String = "Request timed out",
|
|
||||||
cause: Throwable? = null
|
|
||||||
) : NetworkException(
|
|
||||||
message = message,
|
|
||||||
cause = cause,
|
|
||||||
apiError = ApiError(
|
|
||||||
code = "TIMEOUT_ERROR",
|
|
||||||
message = message,
|
|
||||||
details = mapOf("type" to "request_timeout")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
class ServerException(
|
|
||||||
statusCode: Int,
|
|
||||||
message: String = "Server error",
|
|
||||||
cause: Throwable? = null
|
|
||||||
) : NetworkException(
|
|
||||||
message = message,
|
|
||||||
cause = cause,
|
|
||||||
apiError = ApiError(
|
|
||||||
code = "SERVER_ERROR",
|
|
||||||
message = message,
|
|
||||||
details = mapOf(
|
|
||||||
"type" to "server_error",
|
|
||||||
"status_code" to statusCode.toString()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
class ClientException(
|
|
||||||
statusCode: Int,
|
|
||||||
message: String = "Client error",
|
|
||||||
cause: Throwable? = null
|
|
||||||
) : NetworkException(
|
|
||||||
message = message,
|
|
||||||
cause = cause,
|
|
||||||
apiError = ApiError(
|
|
||||||
code = "CLIENT_ERROR",
|
|
||||||
message = message,
|
|
||||||
details = mapOf(
|
|
||||||
"type" to "client_error",
|
|
||||||
"status_code" to statusCode.toString()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
class AuthenticationException(
|
|
||||||
message: String = "Authentication failed",
|
|
||||||
cause: Throwable? = null
|
|
||||||
) : NetworkException(
|
|
||||||
message = message,
|
|
||||||
cause = cause,
|
|
||||||
apiError = ApiError(
|
|
||||||
code = "AUTHENTICATION_ERROR",
|
|
||||||
message = message,
|
|
||||||
details = mapOf("type" to "authentication_failure")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
class AuthorizationException(
|
|
||||||
message: String = "Authorization failed",
|
|
||||||
cause: Throwable? = null
|
|
||||||
) : NetworkException(
|
|
||||||
message = message,
|
|
||||||
cause = cause,
|
|
||||||
apiError = ApiError(
|
|
||||||
code = "AUTHORIZATION_ERROR",
|
|
||||||
message = message,
|
|
||||||
details = mapOf("type" to "authorization_failure")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
class UnknownException(
|
|
||||||
message: String = "Unknown error occurred",
|
|
||||||
cause: Throwable? = null
|
|
||||||
) : NetworkException(
|
|
||||||
message = message,
|
|
||||||
cause = cause,
|
|
||||||
apiError = ApiError(
|
|
||||||
code = "UNKNOWN_ERROR",
|
|
||||||
message = message,
|
|
||||||
details = mapOf("type" to "unknown_error")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extension function to convert various exceptions to NetworkException
|
|
||||||
*/
|
|
||||||
fun Throwable.toNetworkException(): NetworkException {
|
|
||||||
return when (this) {
|
|
||||||
is ConnectTimeoutException -> NetworkException.TimeoutException(
|
|
||||||
message = "Connection timeout: ${this.message}",
|
|
||||||
cause = this
|
|
||||||
)
|
|
||||||
is SocketTimeoutException -> NetworkException.TimeoutException(
|
|
||||||
message = "Socket timeout: ${this.message}",
|
|
||||||
cause = this
|
|
||||||
)
|
|
||||||
is ResponseException -> when (this.response.status.value) {
|
|
||||||
401 -> NetworkException.AuthenticationException(
|
|
||||||
message = "Authentication required",
|
|
||||||
cause = this
|
|
||||||
)
|
|
||||||
403 -> NetworkException.AuthorizationException(
|
|
||||||
message = "Access forbidden",
|
|
||||||
cause = this
|
|
||||||
)
|
|
||||||
in 400..499 -> NetworkException.ClientException(
|
|
||||||
statusCode = this.response.status.value,
|
|
||||||
message = "Client error: ${this.message}",
|
|
||||||
cause = this
|
|
||||||
)
|
|
||||||
in 500..599 -> NetworkException.ServerException(
|
|
||||||
statusCode = this.response.status.value,
|
|
||||||
message = "Server error: ${this.message}",
|
|
||||||
cause = this
|
|
||||||
)
|
|
||||||
else -> NetworkException.UnknownException(
|
|
||||||
message = "HTTP error: ${this.message}",
|
|
||||||
cause = this
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is IOException -> NetworkException.ConnectionException(
|
|
||||||
message = "Network connection failed: ${this.message}",
|
|
||||||
cause = this
|
|
||||||
)
|
|
||||||
is NetworkException -> this
|
|
||||||
else -> NetworkException.UnknownException(
|
|
||||||
message = "Unexpected error: ${this.message}",
|
|
||||||
cause = this
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-217
@@ -1,217 +0,0 @@
|
|||||||
package at.mocode.clients.shared.network
|
|
||||||
|
|
||||||
import at.mocode.clients.shared.data.repository.RepositoryResult
|
|
||||||
import at.mocode.clients.shared.domain.models.ApiError
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
// Using platform-agnostic timestamp handling
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple timestamp provider for multiplatform compatibility
|
|
||||||
*/
|
|
||||||
expect fun currentTimeMillis(): Long
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Network utilities for handling retry logic and resilience
|
|
||||||
*/
|
|
||||||
object NetworkUtils {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retry configuration for network operations
|
|
||||||
*/
|
|
||||||
data class RetryConfig(
|
|
||||||
val maxAttempts: Int = 3,
|
|
||||||
val initialDelayMs: Long = 1000L,
|
|
||||||
val maxDelayMs: Long = 10000L,
|
|
||||||
val backoffMultiplier: Double = 2.0,
|
|
||||||
val retryableExceptions: Set<String> = setOf(
|
|
||||||
"CONNECTION_ERROR",
|
|
||||||
"TIMEOUT_ERROR",
|
|
||||||
"SERVER_ERROR"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute operation with retry logic
|
|
||||||
*/
|
|
||||||
suspend fun <T> withRetry(
|
|
||||||
config: RetryConfig = RetryConfig(),
|
|
||||||
operation: suspend () -> RepositoryResult<T>
|
|
||||||
): RepositoryResult<T> {
|
|
||||||
var lastError: ApiError? = null
|
|
||||||
var currentDelay = config.initialDelayMs
|
|
||||||
|
|
||||||
repeat(config.maxAttempts) { attempt ->
|
|
||||||
try {
|
|
||||||
val result = operation()
|
|
||||||
|
|
||||||
// Return success immediately
|
|
||||||
if (result.isSuccess()) {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the error is retryable
|
|
||||||
val error = result.getErrorOrNull()
|
|
||||||
if (error != null && shouldRetry(error, config)) {
|
|
||||||
lastError = error
|
|
||||||
|
|
||||||
// Don't delay on the last attempt
|
|
||||||
if (attempt < config.maxAttempts - 1) {
|
|
||||||
delay(currentDelay)
|
|
||||||
currentDelay = minOf(
|
|
||||||
(currentDelay * config.backoffMultiplier).toLong(),
|
|
||||||
config.maxDelayMs
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Non-retryable error, return immediately
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
val networkException = e.toNetworkException()
|
|
||||||
lastError = networkException.apiError
|
|
||||||
|
|
||||||
if (shouldRetry(networkException.apiError, config)) {
|
|
||||||
if (attempt < config.maxAttempts - 1) {
|
|
||||||
delay(currentDelay)
|
|
||||||
currentDelay = minOf(
|
|
||||||
(currentDelay * config.backoffMultiplier).toLong(),
|
|
||||||
config.maxDelayMs
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return RepositoryResult.Error(networkException.apiError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// All attempts exhausted, return last error
|
|
||||||
return RepositoryResult.Error(
|
|
||||||
lastError ?: ApiError(
|
|
||||||
code = "MAX_RETRIES_EXCEEDED",
|
|
||||||
message = "Maximum retry attempts exceeded"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an error should trigger a retry
|
|
||||||
*/
|
|
||||||
private fun shouldRetry(error: ApiError, config: RetryConfig): Boolean {
|
|
||||||
return config.retryableExceptions.contains(error.code)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Network connectivity checker (simplified for shared module)
|
|
||||||
*/
|
|
||||||
object ConnectivityChecker {
|
|
||||||
private var isOnline: Boolean = true
|
|
||||||
private var lastCheckMillis: Long = 0L
|
|
||||||
|
|
||||||
fun setOnlineStatus(online: Boolean) {
|
|
||||||
isOnline = online
|
|
||||||
lastCheckMillis = currentTimeMillis()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isOnline(): Boolean = isOnline
|
|
||||||
|
|
||||||
fun getLastCheckMillis(): Long = lastCheckMillis
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple connectivity test by attempting a lightweight operation
|
|
||||||
*/
|
|
||||||
suspend fun checkConnectivity(testOperation: suspend () -> Boolean): Boolean {
|
|
||||||
return try {
|
|
||||||
val result = testOperation()
|
|
||||||
setOnlineStatus(result)
|
|
||||||
result
|
|
||||||
} catch (_: Exception) {
|
|
||||||
setOnlineStatus(false)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Circuit breaker pattern for network operations
|
|
||||||
*/
|
|
||||||
class CircuitBreaker(
|
|
||||||
private val failureThreshold: Int = 5,
|
|
||||||
private val recoveryTimeoutMs: Long = 60000L,
|
|
||||||
private val successThreshold: Int = 3
|
|
||||||
) {
|
|
||||||
private enum class State { CLOSED, OPEN, HALF_OPEN }
|
|
||||||
|
|
||||||
private var state = State.CLOSED
|
|
||||||
private var failureCount = 0
|
|
||||||
private var successCount = 0
|
|
||||||
private var lastFailureTime = 0L
|
|
||||||
|
|
||||||
suspend fun <T> execute(operation: suspend () -> RepositoryResult<T>): RepositoryResult<T> {
|
|
||||||
when (state) {
|
|
||||||
State.OPEN -> {
|
|
||||||
if (currentTimeMillis() - lastFailureTime >= recoveryTimeoutMs) {
|
|
||||||
state = State.HALF_OPEN
|
|
||||||
successCount = 0
|
|
||||||
} else {
|
|
||||||
return RepositoryResult.Error(
|
|
||||||
ApiError(
|
|
||||||
code = "CIRCUIT_BREAKER_OPEN",
|
|
||||||
message = "Circuit breaker is open, requests blocked"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
State.HALF_OPEN -> {
|
|
||||||
// Allow limited requests to test recovery
|
|
||||||
}
|
|
||||||
State.CLOSED -> {
|
|
||||||
// Normal operation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return try {
|
|
||||||
val result = operation()
|
|
||||||
|
|
||||||
if (result.isSuccess()) {
|
|
||||||
onSuccess()
|
|
||||||
} else {
|
|
||||||
onFailure()
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
} catch (e: Exception) {
|
|
||||||
onFailure()
|
|
||||||
val networkException = e.toNetworkException()
|
|
||||||
RepositoryResult.Error(networkException.apiError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onSuccess() {
|
|
||||||
failureCount = 0
|
|
||||||
|
|
||||||
when (state) {
|
|
||||||
State.HALF_OPEN -> {
|
|
||||||
successCount++
|
|
||||||
if (successCount >= successThreshold) {
|
|
||||||
state = State.CLOSED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
state = State.CLOSED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onFailure() {
|
|
||||||
failureCount++
|
|
||||||
lastFailureTime = currentTimeMillis()
|
|
||||||
|
|
||||||
if (failureCount >= failureThreshold) {
|
|
||||||
state = State.OPEN
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getState(): String = state.name
|
|
||||||
fun getFailureCount(): Int = failureCount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-36
@@ -1,36 +0,0 @@
|
|||||||
package at.mocode.clients.shared.presentation.actions
|
|
||||||
|
|
||||||
import at.mocode.clients.shared.domain.models.User
|
|
||||||
import at.mocode.clients.shared.domain.models.AuthToken
|
|
||||||
|
|
||||||
sealed class AppAction {
|
|
||||||
// Auth Actions
|
|
||||||
sealed class Auth : AppAction() {
|
|
||||||
data class LoginStart(val username: String, val password: String) : Auth()
|
|
||||||
data class LoginSuccess(val user: User, val token: AuthToken) : Auth()
|
|
||||||
data class LoginFailure(val error: String) : Auth()
|
|
||||||
object Logout : Auth()
|
|
||||||
data class RefreshToken(val newToken: AuthToken) : Auth()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigation Actions
|
|
||||||
sealed class Navigation : AppAction() {
|
|
||||||
data class NavigateTo(val route: String) : Navigation()
|
|
||||||
object NavigateBack : Navigation()
|
|
||||||
data class UpdateHistory(val route: String) : Navigation()
|
|
||||||
}
|
|
||||||
|
|
||||||
// UI Actions
|
|
||||||
sealed class UI : AppAction() {
|
|
||||||
object ToggleDarkMode : UI()
|
|
||||||
data class SetLoading(val isLoading: Boolean) : UI()
|
|
||||||
data class ShowNotification(val notification: at.mocode.clients.shared.presentation.state.Notification) : UI()
|
|
||||||
data class DismissNotification(val id: String) : UI()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Network Actions
|
|
||||||
sealed class Network : AppAction() {
|
|
||||||
data class SetOnlineStatus(val isOnline: Boolean) : Network()
|
|
||||||
data class UpdateLastSync(val timestamp: String) : Network()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-55
@@ -1,55 +0,0 @@
|
|||||||
package at.mocode.clients.shared.presentation.state
|
|
||||||
|
|
||||||
import at.mocode.clients.shared.domain.models.User
|
|
||||||
import at.mocode.clients.shared.domain.models.AuthToken
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class AppState(
|
|
||||||
val auth: AuthState = AuthState(),
|
|
||||||
val navigation: NavigationState = NavigationState(),
|
|
||||||
val ui: UiState = UiState(),
|
|
||||||
val network: NetworkState = NetworkState()
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class AuthState(
|
|
||||||
val isAuthenticated: Boolean = false,
|
|
||||||
val user: User? = null,
|
|
||||||
val token: AuthToken? = null,
|
|
||||||
val isLoading: Boolean = false,
|
|
||||||
val error: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class NavigationState(
|
|
||||||
val currentRoute: String = "/",
|
|
||||||
val history: List<String> = emptyList(),
|
|
||||||
val canGoBack: Boolean = false
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class UiState(
|
|
||||||
val isDarkMode: Boolean = false,
|
|
||||||
val isLoading: Boolean = false,
|
|
||||||
val notifications: List<Notification> = emptyList()
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class NetworkState(
|
|
||||||
val isOnline: Boolean = true,
|
|
||||||
val lastSync: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Notification(
|
|
||||||
val id: String,
|
|
||||||
val title: String,
|
|
||||||
val message: String,
|
|
||||||
val type: NotificationType = NotificationType.INFO,
|
|
||||||
val timestamp: String
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class NotificationType {
|
|
||||||
INFO, SUCCESS, WARNING, ERROR
|
|
||||||
}
|
|
||||||
-137
@@ -1,137 +0,0 @@
|
|||||||
package at.mocode.clients.shared.presentation.store
|
|
||||||
|
|
||||||
import at.mocode.clients.shared.presentation.state.AppState
|
|
||||||
import at.mocode.clients.shared.presentation.actions.AppAction
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import kotlinx.coroutines.flow.*
|
|
||||||
|
|
||||||
class AppStore(
|
|
||||||
private val dispatcher: CoroutineDispatcher = Dispatchers.Main
|
|
||||||
) {
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
|
|
||||||
private val _state = MutableStateFlow(AppState())
|
|
||||||
|
|
||||||
val state: StateFlow<AppState> = _state.asStateFlow()
|
|
||||||
|
|
||||||
fun dispatch(action: AppAction) {
|
|
||||||
scope.launch {
|
|
||||||
val currentState = _state.value
|
|
||||||
val newState = reduce(currentState, action)
|
|
||||||
_state.value = newState
|
|
||||||
|
|
||||||
// Handle side effects
|
|
||||||
handleSideEffect(action, newState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun reduce(currentState: AppState, action: AppAction): AppState {
|
|
||||||
return when (action) {
|
|
||||||
is AppAction.Auth -> currentState.copy(
|
|
||||||
auth = reduceAuth(currentState.auth, action)
|
|
||||||
)
|
|
||||||
is AppAction.Navigation -> currentState.copy(
|
|
||||||
navigation = reduceNavigation(currentState.navigation, action)
|
|
||||||
)
|
|
||||||
is AppAction.UI -> currentState.copy(
|
|
||||||
ui = reduceUI(currentState.ui, action)
|
|
||||||
)
|
|
||||||
is AppAction.Network -> currentState.copy(
|
|
||||||
network = reduceNetwork(currentState.network, action)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun reduceAuth(currentAuth: at.mocode.clients.shared.presentation.state.AuthState, action: AppAction.Auth): at.mocode.clients.shared.presentation.state.AuthState {
|
|
||||||
return when (action) {
|
|
||||||
is AppAction.Auth.LoginStart -> currentAuth.copy(
|
|
||||||
isLoading = true,
|
|
||||||
error = null
|
|
||||||
)
|
|
||||||
is AppAction.Auth.LoginSuccess -> currentAuth.copy(
|
|
||||||
isAuthenticated = true,
|
|
||||||
user = action.user,
|
|
||||||
token = action.token,
|
|
||||||
isLoading = false,
|
|
||||||
error = null
|
|
||||||
)
|
|
||||||
is AppAction.Auth.LoginFailure -> currentAuth.copy(
|
|
||||||
isAuthenticated = false,
|
|
||||||
user = null,
|
|
||||||
token = null,
|
|
||||||
isLoading = false,
|
|
||||||
error = action.error
|
|
||||||
)
|
|
||||||
is AppAction.Auth.Logout -> at.mocode.clients.shared.presentation.state.AuthState()
|
|
||||||
is AppAction.Auth.RefreshToken -> currentAuth.copy(
|
|
||||||
token = action.newToken
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun reduceNavigation(currentNav: at.mocode.clients.shared.presentation.state.NavigationState, action: AppAction.Navigation): at.mocode.clients.shared.presentation.state.NavigationState {
|
|
||||||
return when (action) {
|
|
||||||
is AppAction.Navigation.NavigateTo -> currentNav.copy(
|
|
||||||
currentRoute = action.route,
|
|
||||||
history = currentNav.history + currentNav.currentRoute,
|
|
||||||
canGoBack = true
|
|
||||||
)
|
|
||||||
is AppAction.Navigation.NavigateBack -> {
|
|
||||||
val newHistory = currentNav.history.dropLast(1)
|
|
||||||
currentNav.copy(
|
|
||||||
currentRoute = newHistory.lastOrNull() ?: "/",
|
|
||||||
history = newHistory,
|
|
||||||
canGoBack = newHistory.isNotEmpty()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is AppAction.Navigation.UpdateHistory -> currentNav.copy(
|
|
||||||
currentRoute = action.route
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun reduceUI(currentUI: at.mocode.clients.shared.presentation.state.UiState, action: AppAction.UI): at.mocode.clients.shared.presentation.state.UiState {
|
|
||||||
return when (action) {
|
|
||||||
is AppAction.UI.ToggleDarkMode -> currentUI.copy(
|
|
||||||
isDarkMode = !currentUI.isDarkMode
|
|
||||||
)
|
|
||||||
is AppAction.UI.SetLoading -> currentUI.copy(
|
|
||||||
isLoading = action.isLoading
|
|
||||||
)
|
|
||||||
is AppAction.UI.ShowNotification -> currentUI.copy(
|
|
||||||
notifications = currentUI.notifications + action.notification
|
|
||||||
)
|
|
||||||
is AppAction.UI.DismissNotification -> currentUI.copy(
|
|
||||||
notifications = currentUI.notifications.filter { it.id != action.id }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun reduceNetwork(currentNetwork: at.mocode.clients.shared.presentation.state.NetworkState, action: AppAction.Network): at.mocode.clients.shared.presentation.state.NetworkState {
|
|
||||||
return when (action) {
|
|
||||||
is AppAction.Network.SetOnlineStatus -> currentNetwork.copy(
|
|
||||||
isOnline = action.isOnline
|
|
||||||
)
|
|
||||||
is AppAction.Network.UpdateLastSync -> currentNetwork.copy(
|
|
||||||
lastSync = action.timestamp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun handleSideEffect(action: AppAction, newState: AppState) {
|
|
||||||
when (action) {
|
|
||||||
is AppAction.Auth.LoginSuccess -> {
|
|
||||||
// Auto-save token to local storage
|
|
||||||
// TODO: Implement storage
|
|
||||||
}
|
|
||||||
is AppAction.Auth.Logout -> {
|
|
||||||
// Clear local storage
|
|
||||||
// TODO: Implement storage cleanup
|
|
||||||
}
|
|
||||||
else -> { /* No side effects */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cleanup() {
|
|
||||||
scope.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-69
@@ -1,69 +0,0 @@
|
|||||||
package at.mocode.clients.shared.presentation.store
|
|
||||||
|
|
||||||
import at.mocode.clients.shared.domain.models.User
|
|
||||||
import at.mocode.clients.shared.domain.models.AuthToken
|
|
||||||
import at.mocode.clients.shared.presentation.actions.AppAction
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlin.test.*
|
|
||||||
|
|
||||||
class AppStoreTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `store should be created successfully`() {
|
|
||||||
val store = AppStore(Dispatchers.Unconfined)
|
|
||||||
assertNotNull(store)
|
|
||||||
store.cleanup()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `auth actions should update state`() {
|
|
||||||
val store = AppStore(Dispatchers.Unconfined)
|
|
||||||
|
|
||||||
// Test login start action
|
|
||||||
store.dispatch(AppAction.Auth.LoginStart("testuser", "password"))
|
|
||||||
|
|
||||||
// Test login success
|
|
||||||
val user = User("1", "test", "test@example.com", "Test", "User")
|
|
||||||
val token = AuthToken("access", "refresh", 3600)
|
|
||||||
store.dispatch(AppAction.Auth.LoginSuccess(user, token))
|
|
||||||
|
|
||||||
// Test logout
|
|
||||||
store.dispatch(AppAction.Auth.Logout)
|
|
||||||
|
|
||||||
store.cleanup()
|
|
||||||
assertTrue(true) // Basic test to verify actions don't throw exceptions
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `navigation actions should work`() {
|
|
||||||
val store = AppStore(Dispatchers.Unconfined)
|
|
||||||
|
|
||||||
store.dispatch(AppAction.Navigation.NavigateTo("/dashboard"))
|
|
||||||
store.dispatch(AppAction.Navigation.NavigateBack)
|
|
||||||
|
|
||||||
store.cleanup()
|
|
||||||
assertTrue(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `ui actions should work`() {
|
|
||||||
val store = AppStore(Dispatchers.Unconfined)
|
|
||||||
|
|
||||||
store.dispatch(AppAction.UI.ToggleDarkMode)
|
|
||||||
store.dispatch(AppAction.UI.SetLoading(true))
|
|
||||||
|
|
||||||
store.cleanup()
|
|
||||||
assertTrue(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `network actions should work`() {
|
|
||||||
val store = AppStore(Dispatchers.Unconfined)
|
|
||||||
|
|
||||||
store.dispatch(AppAction.Network.SetOnlineStatus(false))
|
|
||||||
store.dispatch(AppAction.Network.UpdateLastSync("2024-01-01T12:00:00Z"))
|
|
||||||
|
|
||||||
store.cleanup()
|
|
||||||
assertTrue(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package at.mocode.clients.shared.test
|
|
||||||
|
|
||||||
import kotlin.test.AfterTest
|
|
||||||
import kotlin.test.BeforeTest
|
|
||||||
|
|
||||||
expect fun runBlockingTest(block: suspend () -> Unit)
|
|
||||||
|
|
||||||
abstract class BaseTest {
|
|
||||||
@BeforeTest
|
|
||||||
fun setupTest() {
|
|
||||||
// Set up a common test environment
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterTest
|
|
||||||
fun teardownTest() {
|
|
||||||
// Cleanup test environment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
package at.mocode.clients.shared.network
|
|
||||||
|
|
||||||
import kotlin.js.Date
|
|
||||||
|
|
||||||
actual fun currentTimeMillis(): Long = Date.now().toLong()
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package at.mocode.clients.shared.test
|
|
||||||
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
|
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
|
||||||
actual fun runBlockingTest(block: suspend () -> Unit) {
|
|
||||||
GlobalScope.promise {
|
|
||||||
block()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package at.mocode.clients.shared.network
|
|
||||||
|
|
||||||
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package at.mocode.clients.shared.test
|
|
||||||
|
|
||||||
import kotlinx.coroutines.test.*
|
|
||||||
|
|
||||||
actual fun runBlockingTest(block: suspend () -> Unit) {
|
|
||||||
runTest {
|
|
||||||
block()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-11
@@ -1,11 +0,0 @@
|
|||||||
package at.mocode.clients.shared.network
|
|
||||||
|
|
||||||
// WASM implementation using a simple counter-approach
|
|
||||||
// Since we don't have direct access to system time in WASM,
|
|
||||||
// we'll use a monotonic counter for relative timing
|
|
||||||
private var wasmTimeCounter: Long = 0L
|
|
||||||
|
|
||||||
actual fun currentTimeMillis(): Long {
|
|
||||||
wasmTimeCounter += 1
|
|
||||||
return wasmTimeCounter
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package at.mocode.clients.shared.test
|
|
||||||
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
|
|
||||||
@OptIn(DelicateCoroutinesApi::class, ExperimentalWasmJsInterop::class)
|
|
||||||
actual fun runBlockingTest(block: suspend () -> Unit) {
|
|
||||||
// WASM-JS uses the same approach as regular JS
|
|
||||||
GlobalScope.promise {
|
|
||||||
block()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -141,6 +141,43 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- meldestelle-network
|
- meldestelle-network
|
||||||
|
|
||||||
|
# --- CLIENTS: WEB APP (Kotlin/JS, no WASM) ---
|
||||||
|
web-app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: dockerfiles/clients/web-app/Dockerfile
|
||||||
|
args:
|
||||||
|
GRADLE_VERSION: 9.1.0
|
||||||
|
JAVA_VERSION: 21
|
||||||
|
NODE_VERSION: 22.21.0
|
||||||
|
NGINX_IMAGE_TAG: 1.28.0-alpine
|
||||||
|
WEB_BUILD_PROFILE: dev
|
||||||
|
container_name: meldestelle-web-app
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "4000:4000"
|
||||||
|
depends_on:
|
||||||
|
- api-gateway
|
||||||
|
networks:
|
||||||
|
- meldestelle-network
|
||||||
|
|
||||||
|
# --- CLIENTS: DESKTOP APP (VNC + noVNC) ---
|
||||||
|
desktop-app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: dockerfiles/clients/desktop-app/Dockerfile
|
||||||
|
container_name: meldestelle-desktop-app
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- API_BASE_URL=http://api-gateway:8081
|
||||||
|
ports:
|
||||||
|
- "5901:5901" # VNC
|
||||||
|
- "6080:6080" # noVNC
|
||||||
|
depends_on:
|
||||||
|
- api-gateway
|
||||||
|
networks:
|
||||||
|
- meldestelle-network
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
pgadmin-data:
|
pgadmin-data:
|
||||||
|
|||||||
+52
-8
@@ -29,7 +29,7 @@ services:
|
|||||||
- postgres
|
- postgres
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:8.4-alpine
|
||||||
container_name: ${COMPOSE_PROJECT_NAME}-redis
|
container_name: ${COMPOSE_PROJECT_NAME}-redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -105,7 +105,7 @@ services:
|
|||||||
- pgadmin
|
- pgadmin
|
||||||
|
|
||||||
prometheus:
|
prometheus:
|
||||||
image: prom/prometheus:v2.54.1
|
image: prom/prometheus:v3.7.3
|
||||||
container_name: ${COMPOSE_PROJECT_NAME}-prometheus
|
container_name: ${COMPOSE_PROJECT_NAME}-prometheus
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -128,7 +128,7 @@ services:
|
|||||||
- prometheus
|
- prometheus
|
||||||
|
|
||||||
grafana:
|
grafana:
|
||||||
image: grafana/grafana:11.3.0
|
image: grafana/grafana:12.3
|
||||||
container_name: ${COMPOSE_PROJECT_NAME}-grafana
|
container_name: ${COMPOSE_PROJECT_NAME}-grafana
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -157,7 +157,7 @@ services:
|
|||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
consul:
|
consul:
|
||||||
image: hashicorp/consul:1.15
|
image: hashicorp/consul:1.22.1
|
||||||
container_name: ${COMPOSE_PROJECT_NAME}-consul
|
container_name: ${COMPOSE_PROJECT_NAME}-consul
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -182,7 +182,7 @@ services:
|
|||||||
GRADLE_VERSION: 9.1.0
|
GRADLE_VERSION: 9.1.0
|
||||||
JAVA_VERSION: 21
|
JAVA_VERSION: 21
|
||||||
VERSION: 1.0.0
|
VERSION: 1.0.0
|
||||||
BUILD_DATE: "2025-11-25"
|
BUILD_DATE: "2025-11-29"
|
||||||
container_name: ${COMPOSE_PROJECT_NAME}-gateway
|
container_name: ${COMPOSE_PROJECT_NAME}-gateway
|
||||||
restart: no
|
restart: no
|
||||||
ports:
|
ports:
|
||||||
@@ -230,7 +230,7 @@ services:
|
|||||||
GRADLE_VERSION: 9.1.0
|
GRADLE_VERSION: 9.1.0
|
||||||
JAVA_VERSION: 21
|
JAVA_VERSION: 21
|
||||||
VERSION: 1.0.0
|
VERSION: 1.0.0
|
||||||
BUILD_DATE: "2025-11-21"
|
BUILD_DATE: "2025-11-29"
|
||||||
container_name: ${COMPOSE_PROJECT_NAME}-ping-service
|
container_name: ${COMPOSE_PROJECT_NAME}-ping-service
|
||||||
restart: no # "${RESTART_POLICY:-unless-stopped}"
|
restart: no # "${RESTART_POLICY:-unless-stopped}"
|
||||||
ports:
|
ports:
|
||||||
@@ -246,7 +246,7 @@ services:
|
|||||||
SPRING_CLOUD_CONSUL_PORT: 8500
|
SPRING_CLOUD_CONSUL_PORT: 8500
|
||||||
SPRING_CLOUD_CONSUL_DISCOVERY_HOSTNAME: ping-service
|
SPRING_CLOUD_CONSUL_DISCOVERY_HOSTNAME: ping-service
|
||||||
|
|
||||||
# --- DATENBANK VERBINDUNG (Das hat gefehlt!) ---
|
# --- DATENBANK VERBINDUNG ---
|
||||||
# Wir nutzen die Container-Namen aus deiner .env Variable
|
# Wir nutzen die Container-Namen aus deiner .env Variable
|
||||||
SPRING_DATASOURCE_URL: jdbc:postgresql://${COMPOSE_PROJECT_NAME}-postgres:5432/${POSTGRES_DB}
|
SPRING_DATASOURCE_URL: jdbc:postgresql://${COMPOSE_PROJECT_NAME}-postgres:5432/${POSTGRES_DB}
|
||||||
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
|
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
|
||||||
@@ -254,7 +254,7 @@ services:
|
|||||||
# WICHTIG: Wir wollen nur validieren, nichts erstellen.
|
# WICHTIG: Wir wollen nur validieren, nichts erstellen.
|
||||||
SPRING_JPA_HIBERNATE_DDL_AUTO: validate
|
SPRING_JPA_HIBERNATE_DDL_AUTO: validate
|
||||||
|
|
||||||
# --- REDIS (DAS HAT GEFEHLT!) ---
|
# --- REDIS ---
|
||||||
# Wir nutzen den Service-Namen, genau wie bei Postgres
|
# Wir nutzen den Service-Namen, genau wie bei Postgres
|
||||||
SPRING_DATA_REDIS_HOST: ${COMPOSE_PROJECT_NAME}-redis
|
SPRING_DATA_REDIS_HOST: ${COMPOSE_PROJECT_NAME}-redis
|
||||||
SPRING_DATA_REDIS_PORT: 6379
|
SPRING_DATA_REDIS_PORT: 6379
|
||||||
@@ -270,6 +270,50 @@ services:
|
|||||||
aliases:
|
aliases:
|
||||||
- ping-service
|
- ping-service
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# CLIENT APPLICATIONS
|
||||||
|
# ==========================================
|
||||||
|
web-app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: dockerfiles/clients/web-app/Dockerfile
|
||||||
|
args:
|
||||||
|
GRADLE_VERSION: ${DOCKER_GRADLE_VERSION:-9.1.0}
|
||||||
|
JAVA_VERSION: ${DOCKER_JAVA_VERSION:-21}
|
||||||
|
NODE_VERSION: ${DOCKER_NODE_VERSION:-22.21.0}
|
||||||
|
NGINX_IMAGE_TAG: ${DOCKER_NGINX_VERSION:-1.28.0-alpine}
|
||||||
|
WEB_BUILD_PROFILE: ${WEB_BUILD_PROFILE:-dev}
|
||||||
|
container_name: ${COMPOSE_PROJECT_NAME}-web-app
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${WEB_APP_PORT}"
|
||||||
|
depends_on:
|
||||||
|
api-gateway:
|
||||||
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
meldestelle-network:
|
||||||
|
aliases:
|
||||||
|
- web-app
|
||||||
|
|
||||||
|
desktop-app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: dockerfiles/clients/desktop-app/Dockerfile
|
||||||
|
container_name: ${COMPOSE_PROJECT_NAME}-desktop-app
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
API_BASE_URL: http://api-gateway:8081
|
||||||
|
ports:
|
||||||
|
- "${DESKTOP_APP_VNC_PORT}"
|
||||||
|
- "${DESKTOP_APP_NOVNC_PORT}"
|
||||||
|
depends_on:
|
||||||
|
api-gateway:
|
||||||
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
meldestelle-network:
|
||||||
|
aliases:
|
||||||
|
- desktop-app
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
pgadmin-data:
|
pgadmin-data:
|
||||||
|
|||||||
Vendored
-8
@@ -1,8 +0,0 @@
|
|||||||
# Optional Client Override – Web App
|
|
||||||
# Diese Datei wird zusätzlich zu config/env/.env geladen.
|
|
||||||
# Nur befüllen, wenn die Web-App abweichende Runtime-Werte benötigt.
|
|
||||||
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
|
|
||||||
#
|
|
||||||
# WEB_APP_PORT=4001
|
|
||||||
# NODE_ENV=development
|
|
||||||
# APP_TITLE=Meldestelle (Dev)
|
|
||||||
-8
@@ -1,8 +0,0 @@
|
|||||||
# Optional Infrastructure Override – API Gateway
|
|
||||||
# Diese Datei wird zusätzlich zu config/env/.env geladen.
|
|
||||||
# Nur befüllen, wenn das Gateway abweichende Runtime-Werte benötigt.
|
|
||||||
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
|
|
||||||
#
|
|
||||||
# GATEWAY_PORT=8081
|
|
||||||
# SPRING_PROFILES_ACTIVE=docker,keycloak
|
|
||||||
# LOGGING_LEVEL_ROOT=DEBUG
|
|
||||||
-8
@@ -1,8 +0,0 @@
|
|||||||
# Optional Service Override – Events Service
|
|
||||||
# Diese Datei wird zusätzlich zu config/env/.env geladen.
|
|
||||||
# Nur befüllen, wenn der Events-Service abweichende Runtime-Werte benötigt.
|
|
||||||
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
|
|
||||||
#
|
|
||||||
# SERVER_PORT=8085
|
|
||||||
# LOGGING_LEVEL_ROOT=DEBUG
|
|
||||||
# DEBUG=true
|
|
||||||
-8
@@ -1,8 +0,0 @@
|
|||||||
# Optional Service Override – Horses Service
|
|
||||||
# Diese Datei wird zusätzlich zu config/env/.env geladen.
|
|
||||||
# Nur befüllen, wenn der Horses-Service abweichende Runtime-Werte benötigt.
|
|
||||||
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
|
|
||||||
#
|
|
||||||
# SERVER_PORT=8084
|
|
||||||
# LOGGING_LEVEL_ROOT=DEBUG
|
|
||||||
# DEBUG=true
|
|
||||||
-8
@@ -1,8 +0,0 @@
|
|||||||
# Optional Service Override – Masterdata Service
|
|
||||||
# Diese Datei wird zusätzlich zu config/env/.env geladen.
|
|
||||||
# Nur befüllen, wenn der Masterdata-Service abweichende Runtime-Werte benötigt.
|
|
||||||
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
|
|
||||||
#
|
|
||||||
# SERVER_PORT=8086
|
|
||||||
# LOGGING_LEVEL_ROOT=DEBUG
|
|
||||||
# DEBUG=true
|
|
||||||
-8
@@ -1,8 +0,0 @@
|
|||||||
# Optional Service Override – Members Service
|
|
||||||
# Diese Datei wird zusätzlich zu config/env/.env geladen.
|
|
||||||
# Nur befüllen, wenn der Members-Service abweichende Runtime-Werte benötigt.
|
|
||||||
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
|
|
||||||
#
|
|
||||||
# SERVER_PORT=8083
|
|
||||||
# LOGGING_LEVEL_ROOT=DEBUG
|
|
||||||
# DEBUG=true
|
|
||||||
Vendored
-8
@@ -1,8 +0,0 @@
|
|||||||
# Optional Service Override – Ping Service
|
|
||||||
# Diese Datei wird zusätzlich zu config/env/.env geladen.
|
|
||||||
# Nur befüllen, wenn der Ping-Service abweichende Runtime-Werte benötigt.
|
|
||||||
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
|
|
||||||
#
|
|
||||||
# SERVER_PORT=8082
|
|
||||||
# LOGGING_LEVEL_ROOT=DEBUG
|
|
||||||
# DEBUG=true
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# Core\n\nMinimal placeholder README. See docs/index.md for project documentation
|
|
||||||
@@ -24,6 +24,8 @@ kotlin {
|
|||||||
// Opt-in to experimental Kotlin UUID API across all source sets
|
// Opt-in to experimental Kotlin UUID API across all source sets
|
||||||
all {
|
all {
|
||||||
languageSettings.optIn("kotlin.uuid.ExperimentalUuidApi")
|
languageSettings.optIn("kotlin.uuid.ExperimentalUuidApi")
|
||||||
|
// Opt-in für kotlin.time.ExperimentalTime projektweit, solange Teile noch experimentell sind
|
||||||
|
languageSettings.optIn("kotlin.time.ExperimentalTime")
|
||||||
}
|
}
|
||||||
|
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||||
|
|
||||||
package at.mocode.core.domain.event
|
package at.mocode.core.domain.event
|
||||||
|
|
||||||
import at.mocode.core.domain.model.*
|
import at.mocode.core.domain.model.*
|
||||||
import at.mocode.core.domain.serialization.KotlinInstantSerializer
|
import at.mocode.core.domain.serialization.KotlinxInstantSerializer
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock as KtClock
|
||||||
import kotlin.time.ExperimentalTime
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
@@ -13,7 +13,6 @@ import kotlin.uuid.Uuid
|
|||||||
* Basis-Interface für alle Domain-Events im System.
|
* Basis-Interface für alle Domain-Events im System.
|
||||||
* Ein Domain-Event beschreibt ein fachlich relevantes Ereignis, das stattgefunden hat.
|
* Ein Domain-Event beschreibt ein fachlich relevantes Ereignis, das stattgefunden hat.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalTime::class)
|
|
||||||
interface DomainEvent {
|
interface DomainEvent {
|
||||||
val eventId: EventId
|
val eventId: EventId
|
||||||
val aggregateId: AggregateId
|
val aggregateId: AggregateId
|
||||||
@@ -28,13 +27,13 @@ interface DomainEvent {
|
|||||||
* Abstrakte Basisklasse für Domain-Events, um Boilerplate zu reduzieren.
|
* Abstrakte Basisklasse für Domain-Events, um Boilerplate zu reduzieren.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
@OptIn(ExperimentalTime::class)
|
|
||||||
abstract class BaseDomainEvent(
|
abstract class BaseDomainEvent(
|
||||||
override val aggregateId: AggregateId,
|
override val aggregateId: AggregateId,
|
||||||
override val eventType: EventType,
|
override val eventType: EventType,
|
||||||
override val version: EventVersion,
|
override val version: EventVersion,
|
||||||
override val eventId: EventId = EventId(Uuid.random()),
|
override val eventId: EventId = EventId(Uuid.random()),
|
||||||
@Serializable(with = KotlinInstantSerializer::class)
|
|
||||||
|
@Serializable(with = KotlinxInstantSerializer::class)
|
||||||
override val timestamp: Instant,
|
override val timestamp: Instant,
|
||||||
override val correlationId: CorrelationId? = null,
|
override val correlationId: CorrelationId? = null,
|
||||||
override val causationId: CausationId? = null
|
override val causationId: CausationId? = null
|
||||||
@@ -58,8 +57,7 @@ abstract class BaseDomainEvent(
|
|||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@OptIn(ExperimentalTime::class)
|
private fun createTimestamp(): Instant = Instant.parse(KtClock.System.now().toString())
|
||||||
private fun createTimestamp(): Instant = Clock.System.now()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package at.mocode.core.domain.model
|
package at.mocode.core.domain.model
|
||||||
|
|
||||||
import at.mocode.core.domain.serialization.KotlinInstantSerializer
|
import at.mocode.core.domain.serialization.KotlinxInstantSerializer
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.ExperimentalTime
|
import kotlin.time.ExperimentalTime
|
||||||
@@ -15,14 +15,13 @@ interface BaseDto
|
|||||||
* Basis-DTO für Domänen-Entitäten mit eindeutiger ID und Audit-Zeitstempeln.
|
* Basis-DTO für Domänen-Entitäten mit eindeutiger ID und Audit-Zeitstempeln.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
@OptIn(ExperimentalTime::class)
|
|
||||||
abstract class EntityDto : BaseDto {
|
abstract class EntityDto : BaseDto {
|
||||||
abstract val id: EntityId
|
abstract val id: EntityId
|
||||||
|
|
||||||
@Serializable(with = KotlinInstantSerializer::class)
|
@Serializable(with = KotlinxInstantSerializer::class)
|
||||||
abstract val createdAt: Instant
|
abstract val createdAt: Instant
|
||||||
|
|
||||||
@Serializable(with = KotlinInstantSerializer::class)
|
@Serializable(with = KotlinxInstantSerializer::class)
|
||||||
abstract val updatedAt: Instant
|
abstract val updatedAt: Instant
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,19 +39,21 @@ data class ErrorDto(
|
|||||||
* Standardisierte Hülle für API-Antworten mit einheitlicher Struktur.
|
* Standardisierte Hülle für API-Antworten mit einheitlicher Struktur.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
@OptIn(ExperimentalTime::class)
|
|
||||||
data class ApiResponse<T>(
|
data class ApiResponse<T>(
|
||||||
val data: T?,
|
val data: T?,
|
||||||
val success: Boolean,
|
val success: Boolean,
|
||||||
val errors: List<ErrorDto> = emptyList(),
|
val errors: List<ErrorDto> = emptyList(),
|
||||||
@Serializable(with = KotlinInstantSerializer::class)
|
@Serializable(with = KotlinxInstantSerializer::class)
|
||||||
val timestamp: Instant
|
val timestamp: Instant
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
@OptIn(ExperimentalTime::class)
|
@OptIn(ExperimentalTime::class)
|
||||||
fun <T> success(data: T): ApiResponse<T> {
|
fun <T> success(data: T): ApiResponse<T> =
|
||||||
return ApiResponse(data = data, success = true, timestamp = Clock.System.now())
|
ApiResponse(
|
||||||
}
|
data = data,
|
||||||
|
success = true,
|
||||||
|
timestamp = Instant.parse(Clock.System.now().toString())
|
||||||
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalTime::class)
|
@OptIn(ExperimentalTime::class)
|
||||||
fun <T> error(
|
fun <T> error(
|
||||||
@@ -64,11 +65,10 @@ data class ApiResponse<T>(
|
|||||||
data = null,
|
data = null,
|
||||||
success = false,
|
success = false,
|
||||||
errors = listOf(ErrorDto(code = code, message = message, field = field)),
|
errors = listOf(ErrorDto(code = code, message = message, field = field)),
|
||||||
timestamp = Clock.System.now()
|
timestamp = Instant.parse(Clock.System.now().toString())
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalTime::class)
|
|
||||||
fun <T> error(
|
fun <T> error(
|
||||||
code: String,
|
code: String,
|
||||||
message: String,
|
message: String,
|
||||||
@@ -79,7 +79,12 @@ data class ApiResponse<T>(
|
|||||||
|
|
||||||
@OptIn(ExperimentalTime::class)
|
@OptIn(ExperimentalTime::class)
|
||||||
fun <T> error(errors: List<ErrorDto>): ApiResponse<T> {
|
fun <T> error(errors: List<ErrorDto>): ApiResponse<T> {
|
||||||
return ApiResponse(data = null, success = false, errors = errors, timestamp = Clock.System.now())
|
return ApiResponse(
|
||||||
|
data = null,
|
||||||
|
success = false,
|
||||||
|
errors = errors,
|
||||||
|
timestamp = Instant.parse(Clock.System.now().toString())
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package at.mocode.core.domain.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentrale Sammlung der standardisierten Fehlercodes der Anwendung.
|
||||||
|
* Dient als Single-Source-of-Truth, um Inkonsistenzen zu vermeiden.
|
||||||
|
*/
|
||||||
|
object ErrorCodes {
|
||||||
|
val DUPLICATE_ENTRY = ErrorCode("DUPLICATE_ENTRY")
|
||||||
|
val CONSTRAINT_VIOLATION = ErrorCode("CONSTRAINT_VIOLATION")
|
||||||
|
val FOREIGN_KEY_VIOLATION = ErrorCode("FOREIGN_KEY_VIOLATION")
|
||||||
|
val CHECK_VIOLATION = ErrorCode("CHECK_VIOLATION")
|
||||||
|
val DATABASE_TIMEOUT = ErrorCode("DATABASE_TIMEOUT")
|
||||||
|
val DATABASE_ERROR = ErrorCode("DATABASE_ERROR")
|
||||||
|
val TRANSACTION_ERROR = ErrorCode("TRANSACTION_ERROR")
|
||||||
|
val VALIDATION_ERROR = ErrorCode("VALIDATION_ERROR")
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||||
|
|
||||||
package at.mocode.core.domain.model
|
package at.mocode.core.domain.model
|
||||||
|
|
||||||
import at.mocode.core.domain.serialization.UuidSerializer
|
import at.mocode.core.domain.serialization.UuidSerializer
|
||||||
|
|||||||
+2
-3
@@ -1,7 +1,6 @@
|
|||||||
@file:OptIn(kotlin.time.ExperimentalTime::class)
|
|
||||||
package at.mocode.core.domain.serialization
|
package at.mocode.core.domain.serialization
|
||||||
|
|
||||||
import kotlinx.datetime.Instant
|
import kotlin.time.Instant
|
||||||
import kotlinx.serialization.KSerializer
|
import kotlinx.serialization.KSerializer
|
||||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
@@ -10,7 +9,7 @@ import kotlinx.serialization.encoding.Decoder
|
|||||||
import kotlinx.serialization.encoding.Encoder
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serializer for kotlinx.datetime.Instant.
|
* Serializer for kotlin.time.Instant.
|
||||||
* Uses ISO-8601 string representation.
|
* Uses ISO-8601 string representation.
|
||||||
*/
|
*/
|
||||||
object KotlinxInstantSerializer : KSerializer<Instant> {
|
object KotlinxInstantSerializer : KSerializer<Instant> {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||||
|
|
||||||
package at.mocode.core.utils
|
package at.mocode.core.utils
|
||||||
|
|
||||||
import at.mocode.core.domain.model.*
|
import at.mocode.core.domain.model.*
|
||||||
|
|||||||
@@ -147,10 +147,14 @@ sealed class Result<out T> {
|
|||||||
is Failure -> try {
|
is Failure -> try {
|
||||||
Success(transform(errors))
|
Success(transform(errors))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Failure(listOf(ErrorDto(
|
Failure(
|
||||||
|
listOf(
|
||||||
|
ErrorDto(
|
||||||
code = at.mocode.core.domain.model.ErrorCode("RECOVERY_FAILED"),
|
code = at.mocode.core.domain.model.ErrorCode("RECOVERY_FAILED"),
|
||||||
message = e.message ?: "Recovery failed with an unknown error"
|
message = e.message ?: "Recovery failed with an unknown error"
|
||||||
)))
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,41 +245,55 @@ sealed class Result<out T> {
|
|||||||
inline fun <T> runCatching(operation: () -> T): Result<T> = try {
|
inline fun <T> runCatching(operation: () -> T): Result<T> = try {
|
||||||
success(operation())
|
success(operation())
|
||||||
} catch (e: IllegalArgumentException) {
|
} catch (e: IllegalArgumentException) {
|
||||||
failure(ErrorDto(
|
failure(
|
||||||
|
ErrorDto(
|
||||||
code = at.mocode.core.domain.model.ErrorCode("INVALID_ARGUMENT"),
|
code = at.mocode.core.domain.model.ErrorCode("INVALID_ARGUMENT"),
|
||||||
message = e.message ?: "Invalid argument provided"
|
message = e.message ?: "Invalid argument provided"
|
||||||
))
|
)
|
||||||
|
)
|
||||||
} catch (e: IllegalStateException) {
|
} catch (e: IllegalStateException) {
|
||||||
failure(ErrorDto(
|
failure(
|
||||||
|
ErrorDto(
|
||||||
code = at.mocode.core.domain.model.ErrorCode("INVALID_STATE"),
|
code = at.mocode.core.domain.model.ErrorCode("INVALID_STATE"),
|
||||||
message = e.message ?: "Operation called in invalid state"
|
message = e.message ?: "Operation called in invalid state"
|
||||||
))
|
)
|
||||||
|
)
|
||||||
} catch (e: UnsupportedOperationException) {
|
} catch (e: UnsupportedOperationException) {
|
||||||
failure(ErrorDto(
|
failure(
|
||||||
|
ErrorDto(
|
||||||
code = at.mocode.core.domain.model.ErrorCode("UNSUPPORTED_OPERATION"),
|
code = at.mocode.core.domain.model.ErrorCode("UNSUPPORTED_OPERATION"),
|
||||||
message = e.message ?: "Operation not supported"
|
message = e.message ?: "Operation not supported"
|
||||||
))
|
)
|
||||||
|
)
|
||||||
} catch (e: IndexOutOfBoundsException) {
|
} catch (e: IndexOutOfBoundsException) {
|
||||||
failure(ErrorDto(
|
failure(
|
||||||
|
ErrorDto(
|
||||||
code = at.mocode.core.domain.model.ErrorCode("INDEX_OUT_OF_BOUNDS"),
|
code = at.mocode.core.domain.model.ErrorCode("INDEX_OUT_OF_BOUNDS"),
|
||||||
message = e.message ?: "Index out of bounds"
|
message = e.message ?: "Index out of bounds"
|
||||||
))
|
)
|
||||||
|
)
|
||||||
} catch (e: NullPointerException) {
|
} catch (e: NullPointerException) {
|
||||||
failure(ErrorDto(
|
failure(
|
||||||
|
ErrorDto(
|
||||||
code = at.mocode.core.domain.model.ErrorCode("NULL_REFERENCE"),
|
code = at.mocode.core.domain.model.ErrorCode("NULL_REFERENCE"),
|
||||||
message = e.message ?: "Unexpected null reference"
|
message = e.message ?: "Unexpected null reference"
|
||||||
))
|
)
|
||||||
|
)
|
||||||
} catch (e: ClassCastException) {
|
} catch (e: ClassCastException) {
|
||||||
failure(ErrorDto(
|
failure(
|
||||||
|
ErrorDto(
|
||||||
code = at.mocode.core.domain.model.ErrorCode("TYPE_MISMATCH"),
|
code = at.mocode.core.domain.model.ErrorCode("TYPE_MISMATCH"),
|
||||||
message = e.message ?: "Type mismatch occurred"
|
message = e.message ?: "Type mismatch occurred"
|
||||||
))
|
)
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Fallback for any other exception type
|
// Fallback for any other exception type
|
||||||
failure(ErrorDto(
|
failure(
|
||||||
|
ErrorDto(
|
||||||
code = at.mocode.core.domain.model.ErrorCode("OPERATION_FAILED"),
|
code = at.mocode.core.domain.model.ErrorCode("OPERATION_FAILED"),
|
||||||
message = e.message ?: "Unknown error occurred"
|
message = e.message ?: "Unknown error occurred"
|
||||||
))
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -331,8 +349,10 @@ fun <T> T?.toResult(errorMessage: String = "Value is null"): Result<T> =
|
|||||||
if (this != null) {
|
if (this != null) {
|
||||||
Result.success(this)
|
Result.success(this)
|
||||||
} else {
|
} else {
|
||||||
Result.failure(ErrorDto(
|
Result.failure(
|
||||||
|
ErrorDto(
|
||||||
code = at.mocode.core.domain.model.ErrorCode("NULL_VALUE"),
|
code = at.mocode.core.domain.model.ErrorCode("NULL_VALUE"),
|
||||||
message = errorMessage
|
message = errorMessage
|
||||||
))
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ object ValidationRules {
|
|||||||
*/
|
*/
|
||||||
fun minLength(min: Int): ValidationRule<String> = ValidationRule { fieldName, value ->
|
fun minLength(min: Int): ValidationRule<String> = ValidationRule { fieldName, value ->
|
||||||
if (value.length < min) {
|
if (value.length < min) {
|
||||||
ValidationError.invalidLength(fieldName, "$fieldName must be at least $min characters long")
|
ValidationError.invalidLength(fieldName, "$fieldName muss mindestens $min Zeichen lang sein")
|
||||||
} else null
|
} else null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ object ValidationRules {
|
|||||||
*/
|
*/
|
||||||
fun maxLength(max: Int): ValidationRule<String> = ValidationRule { fieldName, value ->
|
fun maxLength(max: Int): ValidationRule<String> = ValidationRule { fieldName, value ->
|
||||||
if (value.length > max) {
|
if (value.length > max) {
|
||||||
ValidationError.invalidLength(fieldName, "$fieldName must not exceed $max characters")
|
ValidationError.invalidLength(fieldName, "$fieldName darf $max Zeichen nicht überschreiten")
|
||||||
} else null
|
} else null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ object ValidationRules {
|
|||||||
fun email(): ValidationRule<String> = ValidationRule { fieldName, value ->
|
fun email(): ValidationRule<String> = ValidationRule { fieldName, value ->
|
||||||
val emailRegex = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
|
val emailRegex = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
|
||||||
if (!value.matches(emailRegex)) {
|
if (!value.matches(emailRegex)) {
|
||||||
ValidationError.invalidFormat(fieldName, "$fieldName must be a valid email address")
|
ValidationError.invalidFormat(fieldName, "$fieldName muss eine gültige E-Mail-Adresse sein")
|
||||||
} else null
|
} else null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ object ValidationRules {
|
|||||||
*/
|
*/
|
||||||
fun <T : Comparable<T>> min(minValue: T): ValidationRule<T> = ValidationRule { fieldName, value ->
|
fun <T : Comparable<T>> min(minValue: T): ValidationRule<T> = ValidationRule { fieldName, value ->
|
||||||
if (value < minValue) {
|
if (value < minValue) {
|
||||||
ValidationError.invalidRange(fieldName, "$fieldName must be at least $minValue")
|
ValidationError.invalidRange(fieldName, "$fieldName muss mindestens $minValue sein")
|
||||||
} else null
|
} else null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ object ValidationRules {
|
|||||||
*/
|
*/
|
||||||
fun <T : Comparable<T>> max(maxValue: T): ValidationRule<T> = ValidationRule { fieldName, value ->
|
fun <T : Comparable<T>> max(maxValue: T): ValidationRule<T> = ValidationRule { fieldName, value ->
|
||||||
if (value > maxValue) {
|
if (value > maxValue) {
|
||||||
ValidationError.invalidRange(fieldName, "$fieldName must not exceed $maxValue")
|
ValidationError.invalidRange(fieldName, "$fieldName darf $maxValue nicht überschreiten")
|
||||||
} else null
|
} else null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ object ValidationRules {
|
|||||||
*/
|
*/
|
||||||
fun positive(): ValidationRule<Number> = ValidationRule { fieldName, value ->
|
fun positive(): ValidationRule<Number> = ValidationRule { fieldName, value ->
|
||||||
if (value.toDouble() <= 0) {
|
if (value.toDouble() <= 0) {
|
||||||
ValidationError.invalidRange(fieldName, "$fieldName must be positive")
|
ValidationError.invalidRange(fieldName, "$fieldName muss positiv sein")
|
||||||
} else null
|
} else null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ object ValidationRules {
|
|||||||
*/
|
*/
|
||||||
fun nonNegative(): ValidationRule<Number> = ValidationRule { fieldName, value ->
|
fun nonNegative(): ValidationRule<Number> = ValidationRule { fieldName, value ->
|
||||||
if (value.toDouble() < 0) {
|
if (value.toDouble() < 0) {
|
||||||
ValidationError.invalidRange(fieldName, "$fieldName must not be negative")
|
ValidationError.invalidRange(fieldName, "$fieldName darf nicht negativ sein")
|
||||||
} else null
|
} else null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ object ValidationRules {
|
|||||||
*/
|
*/
|
||||||
fun <T> minSize(min: Int): ValidationRule<Collection<T>> = ValidationRule { fieldName, value ->
|
fun <T> minSize(min: Int): ValidationRule<Collection<T>> = ValidationRule { fieldName, value ->
|
||||||
if (value.size < min) {
|
if (value.size < min) {
|
||||||
ValidationError.invalidLength(fieldName, "$fieldName must contain at least $min items")
|
ValidationError.invalidLength(fieldName, "$fieldName muss mindestens $min Elemente enthalten")
|
||||||
} else null
|
} else null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +184,7 @@ object ValidationRules {
|
|||||||
*/
|
*/
|
||||||
fun <T> maxSize(max: Int): ValidationRule<Collection<T>> = ValidationRule { fieldName, value ->
|
fun <T> maxSize(max: Int): ValidationRule<Collection<T>> = ValidationRule { fieldName, value ->
|
||||||
if (value.size > max) {
|
if (value.size > max) {
|
||||||
ValidationError.invalidLength(fieldName, "$fieldName must not contain more than $max items")
|
ValidationError.invalidLength(fieldName, "$fieldName darf nicht mehr als $max Elemente enthalten")
|
||||||
} else null
|
} else null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +219,6 @@ fun String?.validateEmail(fieldName: String): ValidationError? {
|
|||||||
if (this.isNullOrBlank()) return ValidationError.required(fieldName)
|
if (this.isNullOrBlank()) return ValidationError.required(fieldName)
|
||||||
val emailRegex = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
|
val emailRegex = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
|
||||||
return if (!this.matches(emailRegex)) {
|
return if (!this.matches(emailRegex)) {
|
||||||
ValidationError.invalidFormat(fieldName, "$fieldName must be a valid email address")
|
ValidationError.invalidFormat(fieldName, "$fieldName muss eine gültige E-Mail-Adresse sein")
|
||||||
} else null
|
} else null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ class ResultTest {
|
|||||||
assertTrue(combined is Result.Success)
|
assertTrue(combined is Result.Success)
|
||||||
assertEquals(listOf(1, 2), (combined as Result.Success).value)
|
assertEquals(listOf(1, 2), (combined as Result.Success).value)
|
||||||
|
|
||||||
val combinedFail = Result.combine(listOf(f1 as Result<Int>, Result.success(3), Result.failure(ErrorDto(ErrorCode("E3"), ""))))
|
val combinedFail =
|
||||||
|
Result.combine(listOf(f1 as Result<Int>, Result.success(3), Result.failure(ErrorDto(ErrorCode("E3"), ""))))
|
||||||
assertTrue(combinedFail is Result.Failure)
|
assertTrue(combinedFail is Result.Failure)
|
||||||
assertEquals(2, (combinedFail as Result.Failure).errors.size)
|
assertEquals(2, (combinedFail as Result.Failure).errors.size)
|
||||||
}
|
}
|
||||||
@@ -75,7 +76,8 @@ class ResultTest {
|
|||||||
val rec = Result.failure<String>(ErrorDto(ErrorCode("E"), "")).recover { _ -> "fallback" }
|
val rec = Result.failure<String>(ErrorDto(ErrorCode("E"), "")).recover { _ -> "fallback" }
|
||||||
assertTrue(rec is Result.Success)
|
assertTrue(rec is Result.Success)
|
||||||
|
|
||||||
val recFail = Result.failure<String>(ErrorDto(ErrorCode("E"), "")).recoverCatching { _ -> throw IllegalStateException("boom") }
|
val recFail =
|
||||||
|
Result.failure<String>(ErrorDto(ErrorCode("E"), "")).recoverCatching { _ -> throw IllegalStateException("boom") }
|
||||||
assertTrue(recFail is Result.Failure)
|
assertTrue(recFail is Result.Failure)
|
||||||
assertEquals("RECOVERY_FAILED", (recFail as Result.Failure).errors.first().code.value)
|
assertEquals("RECOVERY_FAILED", (recFail as Result.Failure).errors.first().code.value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
package at.mocode.core.utils
|
package at.mocode.core.utils
|
||||||
|
|
||||||
import at.mocode.core.domain.model.ErrorCode
|
import at.mocode.core.domain.model.ErrorCodes
|
||||||
import at.mocode.core.domain.model.ErrorDto
|
import at.mocode.core.domain.model.ErrorDto
|
||||||
import at.mocode.core.domain.model.PagedResponse
|
import at.mocode.core.domain.model.PagedResponse
|
||||||
import org.jetbrains.exposed.sql.*
|
import org.jetbrains.exposed.sql.*
|
||||||
import org.jetbrains.exposed.sql.statements.BatchInsertStatement
|
import org.jetbrains.exposed.sql.statements.BatchInsertStatement
|
||||||
import org.jetbrains.exposed.sql.transactions.transaction
|
import org.jetbrains.exposed.sql.transactions.transaction
|
||||||
import java.sql.SQLException
|
import java.sql.SQLException
|
||||||
|
import java.sql.SQLTimeoutException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JVM-specific database utilities for the Core module.
|
* JVM-specific database utilities for the Core module.
|
||||||
@@ -28,26 +29,36 @@ inline fun <T> transactionResult(
|
|||||||
return try {
|
return try {
|
||||||
val result = transaction(database) { block() }
|
val result = transaction(database) { block() }
|
||||||
Result.success(result)
|
Result.success(result)
|
||||||
|
} catch (e: SQLTimeoutException) {
|
||||||
|
Result.failure(
|
||||||
|
ErrorDto(
|
||||||
|
code = ErrorCodes.DATABASE_TIMEOUT,
|
||||||
|
message = "Datenbank-Operation wegen Timeout fehlgeschlagen"
|
||||||
|
)
|
||||||
|
)
|
||||||
} catch (e: SQLException) {
|
} catch (e: SQLException) {
|
||||||
// Handle specific SQL exceptions
|
// Robustere Fehlerbehandlung über SQLSTATE (Postgres)
|
||||||
val errorCode = when {
|
val mapped = when (e.sqlState) {
|
||||||
e.message?.contains("constraint", ignoreCase = true) == true -> "CONSTRAINT_VIOLATION"
|
// unique_violation
|
||||||
e.message?.contains("duplicate", ignoreCase = true) == true -> "DUPLICATE_ENTRY"
|
"23505" -> ErrorCodes.DUPLICATE_ENTRY
|
||||||
e.message?.contains("timeout", ignoreCase = true) == true -> "DATABASE_TIMEOUT"
|
// foreign_key_violation
|
||||||
else -> "DATABASE_ERROR"
|
"23503" -> ErrorCodes.FOREIGN_KEY_VIOLATION
|
||||||
|
// check_violation
|
||||||
|
"23514" -> ErrorCodes.CHECK_VIOLATION
|
||||||
|
else -> ErrorCodes.DATABASE_ERROR
|
||||||
}
|
}
|
||||||
|
|
||||||
Result.failure(
|
Result.failure(
|
||||||
ErrorDto(
|
ErrorDto(
|
||||||
code = ErrorCode(errorCode),
|
code = mapped,
|
||||||
message = "Database operation failed: ${e.message}"
|
message = "Datenbank-Operation fehlgeschlagen"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(
|
Result.failure(
|
||||||
ErrorDto(
|
ErrorDto(
|
||||||
code = ErrorCode("TRANSACTION_ERROR"),
|
code = ErrorCodes.TRANSACTION_ERROR,
|
||||||
message = "Transaction failed: ${e.message ?: "Unknown error"}"
|
message = "Transaktion fehlgeschlagen"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -145,10 +156,12 @@ object DatabaseUtils {
|
|||||||
fun tableExists(tableName: String, database: Database? = null): Boolean {
|
fun tableExists(tableName: String, database: Database? = null): Boolean {
|
||||||
return try {
|
return try {
|
||||||
transaction(database) {
|
transaction(database) {
|
||||||
// Execute a safer SQL statement to check if table exists
|
// Postgres-spezifischer, robuster Ansatz über to_regclass
|
||||||
val result = exec("SELECT 1 FROM information_schema.tables WHERE table_name = '$tableName' LIMIT 1")
|
val valid = tableName.trim()
|
||||||
// If the query returns a result, the table exists
|
if (!valid.matches(Regex("^[A-Za-z_][A-Za-z0-9_]*$"))) return@transaction false
|
||||||
result != null
|
exec("SELECT to_regclass('$valid')") { rs ->
|
||||||
|
if (rs.next()) rs.getString(1) else null
|
||||||
|
} != null
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
false
|
false
|
||||||
@@ -158,28 +171,55 @@ object DatabaseUtils {
|
|||||||
/**
|
/**
|
||||||
* Creates an index if it doesn't exist.
|
* Creates an index if it doesn't exist.
|
||||||
*/
|
*/
|
||||||
|
@JvmName("createIndexIfNotExistsArray")
|
||||||
fun createIndexIfNotExists(
|
fun createIndexIfNotExists(
|
||||||
tableName: String,
|
tableName: String,
|
||||||
indexName: String,
|
indexName: String,
|
||||||
columns: Array<String>,
|
columns: Array<String>,
|
||||||
unique: Boolean = false,
|
unique: Boolean = false,
|
||||||
database: Database? = null
|
database: Database? = null
|
||||||
|
): Result<Unit> = createIndexIfNotExists(tableName, indexName, *columns, unique = unique, database = database)
|
||||||
|
|
||||||
|
@JvmName("createIndexIfNotExistsVararg")
|
||||||
|
fun createIndexIfNotExists(
|
||||||
|
tableName: String,
|
||||||
|
indexName: String,
|
||||||
|
vararg columns: String,
|
||||||
|
unique: Boolean = false,
|
||||||
|
database: Database? = null
|
||||||
): Result<Unit> {
|
): Result<Unit> {
|
||||||
return transactionResult(database) {
|
return transactionResult(database) {
|
||||||
|
// Einfache Sanitization + Quoting der Identifier
|
||||||
|
fun quoteIdent(name: String): String {
|
||||||
|
require(name.matches(Regex("^[A-Za-z_][A-Za-z0-9_]*$"))) { "Ungültiger Identifier: $name" }
|
||||||
|
return "\"$name\""
|
||||||
|
}
|
||||||
|
|
||||||
val uniqueStr = if (unique) "UNIQUE" else ""
|
val uniqueStr = if (unique) "UNIQUE" else ""
|
||||||
val columnsStr = columns.joinToString(", ")
|
val qTable = quoteIdent(tableName)
|
||||||
val sql = "CREATE $uniqueStr INDEX IF NOT EXISTS $indexName ON $tableName ($columnsStr)"
|
val qIndex = quoteIdent(indexName)
|
||||||
|
val cols = columns.map { quoteIdent(it) }.joinToString(", ")
|
||||||
|
val sql = "CREATE $uniqueStr INDEX IF NOT EXISTS $qIndex ON $qTable ($cols)"
|
||||||
exec(sql)
|
exec(sql)
|
||||||
|
Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes a raw SQL query and returns the number of affected rows.
|
* Führt ein beliebiges SQL-Statement aus (DDL/DML). Liefert keinen Update-Count zurück.
|
||||||
*/
|
*/
|
||||||
fun executeRawSql(sql: String, database: Database? = null): Result<Int> {
|
fun executeRawSql(sql: String, database: Database? = null): Result<Unit> = transactionResult(database) {
|
||||||
return transactionResult(database) {
|
exec(sql)
|
||||||
(exec(sql) ?: 0) as Int
|
Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a raw SQL update statement and returns affected rows.
|
||||||
|
*/
|
||||||
|
fun executeUpdate(sql: String, database: Database? = null): Result<Int> = transactionResult(database) {
|
||||||
|
// Nutzt Exposed PreparedStatementApi, kein AutoCloseable
|
||||||
|
val ps = this.connection.prepareStatement(sql, false)
|
||||||
|
ps.executeUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -182,10 +182,12 @@
|
|||||||
"implicitFlowEnabled": false,
|
"implicitFlowEnabled": false,
|
||||||
"directAccessGrantsEnabled": false,
|
"directAccessGrantsEnabled": false,
|
||||||
"redirectUris": [
|
"redirectUris": [
|
||||||
|
"http://localhost:4000/*",
|
||||||
"http://localhost:3000/*",
|
"http://localhost:3000/*",
|
||||||
"https://app.meldestelle.at/*"
|
"https://app.meldestelle.at/*"
|
||||||
],
|
],
|
||||||
"webOrigins": [
|
"webOrigins": [
|
||||||
|
"http://localhost:4000",
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"https://app.meldestelle.at"
|
"https://app.meldestelle.at"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ COPY gradlew ./
|
|||||||
# Kopiere alle notwendigen Module für Multi-Modul-Projekt
|
# Kopiere alle notwendigen Module für Multi-Modul-Projekt
|
||||||
COPY clients ./clients
|
COPY clients ./clients
|
||||||
COPY core ./core
|
COPY core ./core
|
||||||
|
COPY domains ./domains
|
||||||
COPY platform ./platform
|
COPY platform ./platform
|
||||||
COPY infrastructure ./infrastructure
|
COPY infrastructure ./infrastructure
|
||||||
COPY services ./services
|
COPY services ./services
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
ARG GRADLE_VERSION
|
ARG GRADLE_VERSION
|
||||||
ARG JAVA_VERSION
|
ARG JAVA_VERSION
|
||||||
ARG NODE_VERSION
|
ARG NODE_VERSION
|
||||||
|
ARG NGINX_IMAGE_TAG=1.28.0-alpine
|
||||||
|
# Toggle build profile: dev (default) or prod
|
||||||
|
ARG WEB_BUILD_PROFILE=dev
|
||||||
FROM gradle:${GRADLE_VERSION}-jdk${JAVA_VERSION} AS builder
|
FROM gradle:${GRADLE_VERSION}-jdk${JAVA_VERSION} AS builder
|
||||||
|
|
||||||
# Install Node.js (version aligned with versions.toml)
|
# Install Node.js (version aligned with versions.toml)
|
||||||
@@ -29,6 +32,7 @@ COPY gradlew ./
|
|||||||
# Kopiere alle notwendigen Module für Multi-Modul-Projekt
|
# Kopiere alle notwendigen Module für Multi-Modul-Projekt
|
||||||
COPY clients ./clients
|
COPY clients ./clients
|
||||||
COPY core ./core
|
COPY core ./core
|
||||||
|
COPY domains ./domains
|
||||||
COPY platform ./platform
|
COPY platform ./platform
|
||||||
COPY infrastructure ./infrastructure
|
COPY infrastructure ./infrastructure
|
||||||
COPY services ./services
|
COPY services ./services
|
||||||
@@ -40,21 +44,28 @@ RUN chmod +x ./gradlew
|
|||||||
# Dependencies downloaden (für besseres Caching)
|
# Dependencies downloaden (für besseres Caching)
|
||||||
RUN ./gradlew :clients:app:dependencies --no-configure-on-demand
|
RUN ./gradlew :clients:app:dependencies --no-configure-on-demand
|
||||||
|
|
||||||
# Kotlin/JS Web-App kompilieren (PRODUCTION Build)
|
# Kotlin/JS Web-App kompilieren (Profil wählbar über WEB_BUILD_PROFILE)
|
||||||
RUN ./gradlew :clients:app:jsBrowserDistribution --no-configure-on-demand -Pproduction=true
|
# - dev → jsBrowserDevelopmentExecutable (schneller, Source Maps)
|
||||||
|
# - prod → jsBrowserDistribution (minifiziert, optimiert)
|
||||||
|
RUN if [ "$WEB_BUILD_PROFILE" = "prod" ]; then \
|
||||||
|
./gradlew :clients:app:jsBrowserDistribution --no-configure-on-demand -Pproduction=true; \
|
||||||
|
mkdir -p /app/web-dist && cp -r clients/app/build/dist/js/productionExecutable/* /app/web-dist/; \
|
||||||
|
else \
|
||||||
|
./gradlew :clients:app:jsBrowserDevelopmentExecutable --no-configure-on-demand; \
|
||||||
|
mkdir -p /app/web-dist && cp -r clients/app/build/dist/js/developmentExecutable/* /app/web-dist/; \
|
||||||
|
fi
|
||||||
|
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
# Stage 2: Runtime Stage - Nginx für Static Files + API Proxy
|
# Stage 2: Runtime Stage - Nginx für Static Files + API Proxy
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
# Build arg controls runtime base image tag (build-time only)
|
# Build arg controls runtime base image tag (declared globally to allow usage in FROM)
|
||||||
ARG NGINX_IMAGE_TAG
|
|
||||||
FROM nginx:${NGINX_IMAGE_TAG}
|
FROM nginx:${NGINX_IMAGE_TAG}
|
||||||
|
|
||||||
# Installiere curl für Health-Checks
|
# Installiere curl für Health-Checks
|
||||||
RUN apk add --no-cache curl
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
# Kopiere kompilierte Web-App von Build-Stage
|
# Kopiere kompilierte Web-App von Build-Stage (vereinheitlichtes Ausgabeverzeichnis)
|
||||||
COPY --from=builder /app/clients/app/build/dist/js/productionExecutable/ /usr/share/nginx/html/
|
COPY --from=builder /app/web-dist/ /usr/share/nginx/html/
|
||||||
|
|
||||||
# Kopiere Nginx-Konfiguration
|
# Kopiere Nginx-Konfiguration
|
||||||
COPY dockerfiles/clients/web-app/nginx.conf /etc/nginx/nginx.conf
|
COPY dockerfiles/clients/web-app/nginx.conf /etc/nginx/nginx.conf
|
||||||
@@ -62,6 +73,9 @@ COPY dockerfiles/clients/web-app/nginx.conf /etc/nginx/nginx.conf
|
|||||||
# Exponiere Port 4000 (statt Standard 80)
|
# Exponiere Port 4000 (statt Standard 80)
|
||||||
EXPOSE 4000
|
EXPOSE 4000
|
||||||
|
|
||||||
|
# Downloads (Platzhalter) ausliefern lassen
|
||||||
|
COPY dockerfiles/clients/web-app/downloads/ /usr/share/nginx/html/downloads/
|
||||||
|
|
||||||
# Health-Check für Container
|
# Health-Check für Container
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||||
CMD curl -f http://localhost:4000/ || exit 1
|
CMD curl -f http://localhost:4000/ || exit 1
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Meldestelle – Desktop Downloads (Platzhalter)</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', 'Liberation Sans', sans-serif; margin: 2rem; }
|
||||||
|
h1 { margin-bottom: .25rem; }
|
||||||
|
.muted { color: #666; }
|
||||||
|
ul { line-height: 1.8; }
|
||||||
|
.card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 1rem 1.25rem; max-width: 720px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Desktop Downloads</h1>
|
||||||
|
<p class="muted">Platzhalter-Verzeichnis. Hier können zukünftig Installer/Archive der Desktop-App bereitgestellt werden.</p>
|
||||||
|
<div class="card">
|
||||||
|
<p>Lege deine Dateien in dieses Verzeichnis im Repository:</p>
|
||||||
|
<pre><code>dockerfiles/clients/web-app/downloads/</code></pre>
|
||||||
|
<p>Oder mounte in Docker Compose ein Host-Verzeichnis auf <code>/usr/share/nginx/html/downloads</code>.</p>
|
||||||
|
<p>Beispiele (geplant):</p>
|
||||||
|
<ul>
|
||||||
|
<li>Meldestelle-Setup-1.0.0.msi (Windows)</li>
|
||||||
|
<li>Meldestelle-1.0.0.dmg (macOS)</li>
|
||||||
|
<li>Meldestelle-1.0.0.deb (Linux)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user