From 637d610a5b0aa178070caaa909a465fe220a7525 Mon Sep 17 00:00:00 2001 From: Stefan Mogeritsch Date: Tue, 27 Jan 2026 15:12:58 +0100 Subject: [PATCH] refactor: enhance platform configuration, database schema handling, and Keycloak setup Improved `PlatformConfig` API base URL resolution with enhanced logging and fallback logic. Revised database initialization with version checks, schema migration, and error handling. Updated Keycloak configuration to enable `Direct Access Grants` and refine CORS/redirect settings. Adjusted Webpack proxy settings for correct API routing. --- config/docker/keycloak/meldestelle-realm.json | 9 +- docs/06_Frontend/web-setup.md | 29 +++++- .../frontend/core/auth/data/AuthApiClient.kt | 14 ++- .../frontend/core/auth/di/AuthModule.kt | 4 +- .../core/auth/presentation/LoginScreen.kt | 18 +++- .../core/auth/presentation/LoginViewModel.kt | 5 +- .../core/localdb/DatabaseDriverFactory.js.kt | 67 +++++++++++++- .../frontend/core/network/NetworkModule.kt | 91 +++++++++++++++---- .../core/network/PlatformConfig.js.kt | 18 +++- .../feature/data/PingEventRepositoryImpl.kt | 7 +- .../at/mocode/shared/core/AppConstants.kt | 5 +- .../webpack.config.d/webpack.config.js | 21 ++++- 12 files changed, 250 insertions(+), 38 deletions(-) diff --git a/config/docker/keycloak/meldestelle-realm.json b/config/docker/keycloak/meldestelle-realm.json index 8c82d386..63264ce8 100644 --- a/config/docker/keycloak/meldestelle-realm.json +++ b/config/docker/keycloak/meldestelle-realm.json @@ -92,7 +92,9 @@ "webOrigins": [ "http://localhost:8081", "http://localhost:3000", - "https://app.meldestelle.at" + "https://app.meldestelle.at", + "http://localhost:8080", + "*" ], "protocol": "openid-connect", "bearerOnly": false, @@ -189,7 +191,7 @@ "publicClient": true, "standardFlowEnabled": true, "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, + "directAccessGrantsEnabled": true, "redirectUris": [ "http://localhost:8080/*", "http://localhost:4000/*", @@ -200,7 +202,8 @@ "http://localhost:8080", "http://localhost:4000", "http://localhost:3000", - "https://app.meldestelle.at" + "https://app.meldestelle.at", + "*" ], "protocol": "openid-connect", "attributes": { diff --git a/docs/06_Frontend/web-setup.md b/docs/06_Frontend/web-setup.md index b4124d7e..bfb3ce0d 100644 --- a/docs/06_Frontend/web-setup.md +++ b/docs/06_Frontend/web-setup.md @@ -24,11 +24,36 @@ Um die UI nicht zu blockieren, werden alle Datenbank-Operationen in einem separa * **`sqlite.worker.js`:** Ein benutzerdefinierter Worker, der die SQLDelight-Datenbank initialisiert und die Anfragen vom Haupt-Thread entgegennimmt. * **`WebWorkerDriver`:** Der SQLDelight-Treiber, der die Kommunikation zwischen dem Haupt-Thread und dem Worker-Thread managed. +### Datenbank-Initialisierung (Fix 27.01.2026) + +Um Probleme mit `table already exists` beim Neustart zu vermeiden, wurde die `DatabaseDriverFactory.js.kt` angepasst: +* Prüfung der `PRAGMA user_version`. +* Schema-Erstellung nur bei Version 0. +* Migration bei Version < Schema-Version. +* Workaround für fehlende `QueryResult.map` Funktion im JS-Treiber durch explizite Typisierung und Cursor-Capture. + +## Authentifizierung & CORS + +### Keycloak Konfiguration + +Für die Web-App (`web-app` Client) gelten folgende Einstellungen: +* **Access Type:** `public` (Kein Client Secret senden!) +* **Direct Access Grants:** `Enabled` (für Login via Username/Passwort API) +* **Web Origins:** `*` (oder spezifische URL) für CORS. + +### Webpack Proxy + +Der Webpack Dev Server leitet API-Anfragen (`/api/...`) an das API Gateway (`http://localhost:8081`) weiter. +* **Wichtig:** `pathRewrite` wurde entfernt, damit `/api/ping` korrekt als `/api/ping` beim Gateway ankommt (und nicht als `/ping`). +* **Base URL:** Die `PlatformConfig.js.kt` setzt die `baseUrl` für den `apiClient` auf `window.location.origin + "/api"` (z.B. `http://localhost:8080/api`), damit der Proxy greift. + ## Build-Konfiguration * **`build.gradle.kts`:** Im `build.gradle.kts` des `meldestelle-portal`-Moduls wird das `wasmJs` oder `js` Target entsprechend konfiguriert, um die Web-Anwendung zu bauen. * **Webpack-Integration:** Die Gradle-Plugins für KMP/JS und Compose for Web kümmern sich um die Integration mit Webpack und die Erstellung des finalen JavaScript-Bundles. -## Aktueller Blocker (Januar 2026) +## Aktueller Status (27.01.2026) -Derzeit ist der Build für das `wasmJs`-Target aufgrund von Compiler-Fehlern im Zusammenhang mit dem JS-Interop und fehlenden DOM-API-Referenzen blockiert. Die Behebung dieses Problems hat höchste Priorität. +* **Login:** Funktioniert (CORS & Client Config gefixt). +* **Ping-Services:** Funktionieren (Routing & Auth gefixt). +* **Sync:** `404 Not Found` Problem identifiziert (URL-Pfad-Auflösung). Fix implementiert (relativer Pfad im `LoginViewModel`), Verifikation ausstehend. diff --git a/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/data/AuthApiClient.kt b/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/data/AuthApiClient.kt index 35aff3ab..8cf30a96 100644 --- a/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/data/AuthApiClient.kt +++ b/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/data/AuthApiClient.kt @@ -43,9 +43,19 @@ class AuthApiClient( formParameters = Parameters.build { append("grant_type", "password") append("client_id", clientId) - if (!clientSecret.isNullOrBlank()) { + + // IMPORTANT: Only send client_secret if it's NOT a public client (like 'web-app') + // Keycloak rejects requests from public clients that contain a client_secret. + // We check if the client ID suggests a public client or if secret is explicitly provided. + // For now, we rely on the fact that 'web-app' is public and should NOT have a secret sent. + + // Logic: If clientId is 'web-app', we force ignore the secret, or we rely on caller to pass null. + // Since AppConstants might still have the secret for 'postman-client', we need to be careful. + + if (!clientSecret.isNullOrBlank() && clientId != "web-app") { append("client_secret", clientSecret) } + append("username", username) append("password", password) } @@ -89,7 +99,7 @@ class AuthApiClient( formParameters = Parameters.build { append("grant_type", "refresh_token") append("client_id", clientId) - if (!clientSecret.isNullOrBlank()) { + if (!clientSecret.isNullOrBlank() && clientId != "web-app") { append("client_secret", clientSecret) } append("refresh_token", refreshToken) diff --git a/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/di/AuthModule.kt b/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/di/AuthModule.kt index 447745db..d198a49d 100644 --- a/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/di/AuthModule.kt +++ b/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/di/AuthModule.kt @@ -28,9 +28,11 @@ val authModule = module { // Bridge to core network TokenProvider without adding a hard dependency there single { + // We need to capture the AuthTokenManager instance to avoid issues with 'this' context in JS + val tokenManager = get() object : TokenProvider { override fun getAccessToken(): String? { - return get().getToken() + return tokenManager.getToken() } } } diff --git a/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/presentation/LoginScreen.kt b/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/presentation/LoginScreen.kt index 44f15a65..8a1fd9f4 100644 --- a/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/presentation/LoginScreen.kt +++ b/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/presentation/LoginScreen.kt @@ -5,6 +5,8 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -14,6 +16,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -26,6 +29,7 @@ fun LoginScreen( ) { val uiState by viewModel.uiState.collectAsState() val passwordFocusRequester = remember { FocusRequester() } + var passwordVisible by remember { mutableStateOf(false) } Scaffold( topBar = { @@ -75,7 +79,19 @@ fun LoginScreen( enabled = !uiState.isLoading, isError = uiState.passwordError != null, supportingText = uiState.passwordError?.let { { Text(it) } }, - visualTransformation = PasswordVisualTransformation(), + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + val image = if (passwordVisible) + Icons.Filled.Visibility + else + Icons.Filled.VisibilityOff + + val description = if (passwordVisible) "Passwort verbergen" else "Passwort anzeigen" + + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon(imageVector = image, description) + } + }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Password, imeAction = ImeAction.Done diff --git a/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/presentation/LoginViewModel.kt b/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/presentation/LoginViewModel.kt index f91db027..88818ffe 100644 --- a/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/presentation/LoginViewModel.kt +++ b/frontend/core/auth/src/commonMain/kotlin/at/mocode/frontend/core/auth/presentation/LoginViewModel.kt @@ -114,7 +114,10 @@ class LoginViewModel( viewModelScope.launch { try { // Fire-and-forget sync call; Bearer token added by Ktor Auth plugin - apiClient.post("/api/members/sync") + // IMPORTANT: Use relative path (no leading slash) so Ktor appends it to baseUrl + // baseUrl is http://localhost:8080/api (JS) or http://localhost:8081 (JVM) + // Result: http://localhost:8080/api/members/sync -> Proxy -> http://localhost:8081/api/members/sync + apiClient.post("members/sync") } catch (_: Exception) { // Non-fatal: Wir zeigen Sync-Fehler im Login nicht an } diff --git a/frontend/core/local-db/src/jsMain/kotlin/at/mocode/frontend/core/localdb/DatabaseDriverFactory.js.kt b/frontend/core/local-db/src/jsMain/kotlin/at/mocode/frontend/core/localdb/DatabaseDriverFactory.js.kt index 6425175e..c2a6b40b 100644 --- a/frontend/core/local-db/src/jsMain/kotlin/at/mocode/frontend/core/localdb/DatabaseDriverFactory.js.kt +++ b/frontend/core/local-db/src/jsMain/kotlin/at/mocode/frontend/core/localdb/DatabaseDriverFactory.js.kt @@ -1,5 +1,7 @@ package at.mocode.frontend.core.localdb +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlCursor import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.worker.WebWorkerDriver import org.w3c.dom.Worker @@ -11,11 +13,72 @@ actual class DatabaseDriverFactory { val worker = createWorker() val driver = WebWorkerDriver(worker) - // Initialize schema asynchronously - AppDatabase.Schema.create(driver).await() + try { + val version = getVersion(driver) + val schemaVersion = AppDatabase.Schema.version + + console.log("Database version check: Current=$version, Schema=$schemaVersion") + + if (version == 0L) { + console.log("Creating Database Schema...") + try { + AppDatabase.Schema.create(driver).await() + setVersion(driver, schemaVersion) + console.log("Database Schema created and version set to $schemaVersion") + } catch (e: Throwable) { + // If tables already exist but version was 0 (e.g. previous broken run), we might get here. + val msg = e.message ?: "" + if (msg.contains("already exists", ignoreCase = true)) { + console.warn("Tables already exist but version was 0. Assuming DB is initialized. Setting version to $schemaVersion.") + setVersion(driver, schemaVersion) + } else { + throw e + } + } + } else if (version < schemaVersion) { + console.log("Migrating Database Schema from $version to $schemaVersion...") + AppDatabase.Schema.migrate(driver, version, schemaVersion).await() + setVersion(driver, schemaVersion) + console.log("Database Schema migrated") + } else { + console.log("Database Schema is up to date.") + } + } catch (e: Throwable) { + console.error("Error initializing database schema:", e) + throw e + } return driver } + + private suspend fun getVersion(driver: SqlDriver): Long { + // Workaround for QueryResult issues: + // We capture the cursor in a local variable and return the Boolean result from next(). + // Then we read from the captured cursor. + + var cursorRef: SqlCursor? = null + + // executeQuery returns QueryResult because mapper returns QueryResult + val hasNext = driver.executeQuery( + identifier = null, + sql = "PRAGMA user_version;", + mapper = { cursor -> + cursorRef = cursor + cursor.next() + }, + parameters = 0 + ).await() + + return if (hasNext) { + cursorRef?.getLong(0) ?: 0L + } else { + 0L + } + } + + private suspend fun setVersion(driver: SqlDriver, version: Long) { + driver.execute(null, "PRAGMA user_version = $version;", 0).await() + } } // Helper function to create the worker diff --git a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/NetworkModule.kt b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/NetworkModule.kt index ffec26a6..2fcf78ba 100644 --- a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/NetworkModule.kt +++ b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/NetworkModule.kt @@ -42,12 +42,8 @@ val networkModule = module { // 2. API Client (Configured for Gateway & Auth Header) single(named("apiClient")) { // Resolve TokenProvider lazily to avoid circular dependency issues during init - val tokenProvider: TokenProvider? = try { - get() - } catch (_: Throwable) { - println("[apiClient] Warning: No TokenProvider found in Koin") - null - } + // We use a provider lambda to get the TokenProvider instance when needed + // This avoids resolving it immediately during module definition HttpClient { // JSON (kotlinx) configuration @@ -96,18 +92,79 @@ val networkModule = module { }.also { client -> // Dynamic Auth Header Injection via HttpSend plugin // This ensures we get the CURRENT token for each request - if (tokenProvider != null) { - client.plugin(HttpSend).intercept { request -> - try { - val token = tokenProvider.getAccessToken() - if (token != null) { - request.header("Authorization", "Bearer $token") - } - } catch (e: Exception) { - println("[apiClient] Error getting access token: $e") - } - execute(request) + client.plugin(HttpSend).intercept { request -> + try { + // Resolve TokenProvider dynamically from Koin scope + // This assumes Koin is initialized and accessible + // Since we are inside a Koin component, we should be able to get it? + // No, 'this' here is HttpSendScope. + + // We need to capture the Koin scope or use GlobalContext if necessary, + // BUT better: we inject the TokenProvider into the module definition lambda + // and use it here. + + // However, `get()` might fail if not yet registered. + // Let's try to resolve it safely. + + // The issue with the previous code was likely that `get()` was called + // during module definition time (or bean creation time), and if it wasn't ready or + // if it was null (due to try-catch), the interceptor logic was skipped or broken. + + // Let's try to get it from the Koin instance that created this client. + // But we are inside `single { ... }`. + + // We can capture the `Scope` from the `single` block. + // val scope = this // Koin Scope + + // But we can't easily pass `scope` into `intercept`. + + // Let's try to resolve TokenProvider lazily using a lazy delegate or similar. + // Or just resolve it inside the interceptor if we can access Koin. + + // Since we are in `single`, we can get the provider. + // The previous error `TypeError: this.getToken_wiq2bn_k$ is not a function` + // was in AuthModule, which we fixed. + + // The current error `Error_0: Fail to fetch` is a CORS error on the network level, + // NOT a JS runtime error in the interceptor (unless the interceptor causes it). + + // Wait, the logs show: + // [baseClient] REQUEST: .../token + // Access to fetch at ... blocked by CORS policy + + // This confirms it is a CORS issue on the Keycloak server side, or the browser side. + // The JS error `TypeError` is GONE in the latest log! + + // So the interceptor logic in NetworkModule might be fine, or at least not the cause of the CORS error. + // But let's make it robust anyway. + + // We will use a safe lazy resolution pattern. + } catch (e: Exception) { + // ignore } + execute(request) + } + + // Re-applying the logic with proper Koin resolution + val koinScope = this@single + + client.plugin(HttpSend).intercept { request -> + try { + // Attempt to resolve TokenProvider from the capturing scope + val tokenProvider = try { + koinScope.get() + } catch (e: Exception) { + null + } + + val token = tokenProvider?.getAccessToken() + if (token != null) { + request.header("Authorization", "Bearer $token") + } + } catch (e: Exception) { + println("[apiClient] Error injecting auth header: $e") + } + execute(request) } } } diff --git a/frontend/core/network/src/jsMain/kotlin/at/mocode/frontend/core/network/PlatformConfig.js.kt b/frontend/core/network/src/jsMain/kotlin/at/mocode/frontend/core/network/PlatformConfig.js.kt index fd9c79da..1cbbc626 100644 --- a/frontend/core/network/src/jsMain/kotlin/at/mocode/frontend/core/network/PlatformConfig.js.kt +++ b/frontend/core/network/src/jsMain/kotlin/at/mocode/frontend/core/network/PlatformConfig.js.kt @@ -13,7 +13,10 @@ actual object PlatformConfig { } catch (_: dynamic) { "" } - if (fromGlobal.isNotEmpty()) return fromGlobal.removeSuffix("/") + if (fromGlobal.isNotEmpty()) { + console.log("[PlatformConfig] Resolved API_BASE_URL from global: $fromGlobal") + return fromGlobal.removeSuffix("/") + } // 2) Try window location origin (same origin gateway/proxy setup) val origin = try { @@ -21,9 +24,16 @@ actual object PlatformConfig { } catch (_: dynamic) { null } - if (!origin.isNullOrBlank()) return origin.removeSuffix("/") - // 3) Fallback to the local gateway - return "http://localhost:8081" + if (!origin.isNullOrBlank()) { + val resolvedUrl = origin.removeSuffix("/") + "/api" + console.log("[PlatformConfig] Resolved API_BASE_URL from window.location.origin: $resolvedUrl") + return resolvedUrl + } + + // 3) Fallback to the local gateway directly (e.g. for tests without window) + val fallbackUrl = "http://localhost:8081/api" + console.log("[PlatformConfig] Fallback API_BASE_URL: $fallbackUrl") + return fallbackUrl } } diff --git a/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/data/PingEventRepositoryImpl.kt b/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/data/PingEventRepositoryImpl.kt index a660c7b3..defbe0b1 100644 --- a/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/data/PingEventRepositoryImpl.kt +++ b/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/ping/feature/data/PingEventRepositoryImpl.kt @@ -13,11 +13,12 @@ class PingEventRepositoryImpl( ) : SyncableRepository { // The `since` parameter for our sync is the ID of the last event, not a timestamp. - override suspend fun getLatestSince(): String? = withContext(Dispatchers.Default) { - db.appDatabaseQueries.selectLatestPingEventId().executeAsOneOrNull() + override suspend fun getLatestSince(): String? { + // Direct call, no withContext needed if a driver handles threading (which it does) + return db.appDatabaseQueries.selectLatestPingEventId().executeAsOneOrNull() } - override suspend fun upsert(items: List) = withContext(Dispatchers.Default) { + override suspend fun upsert(items: List) { // Always perform bulk operations within a transaction. db.transaction { items.forEach { event -> diff --git a/frontend/shared/src/commonMain/kotlin/at/mocode/shared/core/AppConstants.kt b/frontend/shared/src/commonMain/kotlin/at/mocode/shared/core/AppConstants.kt index 75375cc8..640d7fc4 100644 --- a/frontend/shared/src/commonMain/kotlin/at/mocode/shared/core/AppConstants.kt +++ b/frontend/shared/src/commonMain/kotlin/at/mocode/shared/core/AppConstants.kt @@ -15,11 +15,14 @@ object AppConstants { // Use 'postman-client' for Desktop App Password Flow (Direct Access Grants enabled) // 'web-app' is for Browser Flow (PKCE) - const val KEYCLOAK_CLIENT_ID: String = "postman-client" + // TODO: Make this platform-dependent (Desktop vs Web) + const val KEYCLOAK_CLIENT_ID: String = "web-app" // DEV ONLY: Client Secret for 'postman-client' (Confidential Client) // In Production, this should NEVER be in the frontend code. // For the Desktop App Pilot, we use this to simulate a secure client. + // For 'web-app' (Public Client), this is not needed/used if configured correctly, + // but our AuthApiClient might be sending it. const val KEYCLOAK_CLIENT_SECRET: String = "postman-secret-123" // Removed unused browser flow URLs (registerUrl, loginUrl, etc.) as we focus on Desktop App. diff --git a/frontend/shells/meldestelle-portal/webpack.config.d/webpack.config.js b/frontend/shells/meldestelle-portal/webpack.config.d/webpack.config.js index 6ac55e93..2cd6bdd8 100644 --- a/frontend/shells/meldestelle-portal/webpack.config.d/webpack.config.js +++ b/frontend/shells/meldestelle-portal/webpack.config.d/webpack.config.js @@ -41,7 +41,26 @@ if (config.devServer) { target: 'http://localhost:8081', changeOrigin: true, secure: false, - pathRewrite: {'^/api': ''} + // WICHTIG: pathRewrite entfernt /api, wenn das Backend unter /api lauscht, + // ist das falsch. Wenn das Backend unter / lauscht, ist es richtig. + // Das API Gateway lauscht unter http://localhost:8081/api/... + // Wenn wir also /api/ping aufrufen, soll es zu http://localhost:8081/api/ping gehen. + // Daher KEIN pathRewrite, wenn das Gateway selbst /api erwartet. + // Wenn das Gateway aber die Routen ohne /api mappt (z.B. /ping), dann brauchen wir Rewrite. + // + // Analyse: + // Gateway Routes sind oft: /api/ping -> Ping Service /api/ping oder /ping + // Wenn Gateway Routes definiert sind als: + // - id: ping-service + // uri: lb://ping-service + // predicates: + // - Path=/api/ping/** + // + // Dann leitet das Gateway /api/ping weiter. + // Wenn wir pathRewrite machen, kommt beim Gateway nur /ping an. + // Das Gateway matcht aber auf /api/ping. + // Also: pathRewrite entfernen! + // pathRewrite: {'^/api': ''} } ] }