refactor(auth): migrate Auth module from feature to core package
- Reorganized `auth-feature` into `core/auth` to improve architecture and modularity. - Removed unused PKCE and OAuth callback utilities (`AuthCallbackParams`, `OAuthPkceService`). - Updated imports and adjusted build scripts to reflect new module structure. - Refactored `LoginScreen` and `PingScreen` to include `onBack` functionality in top bars for improved navigation. - Corrected sync endpoint in `PingSyncService`.
This commit is contained in:
parent
c692a2395c
commit
d7cf8200e1
50
docs/90_Reports/2026-01-22_Frontend_Auth_Status.md
Normal file
50
docs/90_Reports/2026-01-22_Frontend_Auth_Status.md
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
---
|
||||||
|
type: Report
|
||||||
|
status: DRAFT
|
||||||
|
owner: Frontend Expert
|
||||||
|
date: 2026-01-22
|
||||||
|
tags: [frontend, auth, refactoring]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🚩 Statusbericht: Frontend Authentifizierung & Refactoring
|
||||||
|
|
||||||
|
**Status:** ✅ **Erfolgreich implementiert**
|
||||||
|
|
||||||
|
Wir haben die Authentifizierung im Frontend implementiert und dabei signifikante Verbesserungen an der Architektur vorgenommen.
|
||||||
|
|
||||||
|
### 🎯 Erreichte Meilensteine
|
||||||
|
|
||||||
|
1. **Architektur-Hygiene:**
|
||||||
|
* `auth-feature` ist nun `core-auth` (Infrastruktur statt Feature).
|
||||||
|
* Design-System Packages sind bereinigt.
|
||||||
|
* Klare Trennung von UI und Logik.
|
||||||
|
|
||||||
|
2. **Login-Flow (Desktop):**
|
||||||
|
* Login mit Username/Passwort funktioniert (`postman-client`).
|
||||||
|
* Token-Management (In-Memory) funktioniert.
|
||||||
|
* Logout funktioniert sauber.
|
||||||
|
|
||||||
|
3. **Backend-Integration:**
|
||||||
|
* `Secure Ping` und `Sync` Endpunkte sind mit Token erreichbar.
|
||||||
|
* 401/403 Fehler werden korrekt behandelt.
|
||||||
|
|
||||||
|
### 🔍 Testergebnisse
|
||||||
|
|
||||||
|
| Szenario | Erwartet | Ergebnis | Status |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **Login (korrekt)** | 200 OK, Token erhalten | 200 OK | ✅ |
|
||||||
|
| **Login (falsch)** | 401 Unauthorized | 401 Unauthorized | ✅ |
|
||||||
|
| **Secure Ping (ohne Login)** | 401 Unauthorized | 401 Unauthorized | ✅ |
|
||||||
|
| **Secure Ping (mit Login)** | 200 OK | 200 OK | ✅ |
|
||||||
|
| **Sync (mit Login)** | 200 OK | 200 OK | ✅ |
|
||||||
|
| **Logout** | Token gelöscht, UI Reset | Funktioniert | ✅ |
|
||||||
|
|
||||||
|
### 📝 Nächste Schritte
|
||||||
|
|
||||||
|
1. **User-Info Parsing:** Korrektes Auslesen des Usernamens (`preferred_username`) aus dem Token.
|
||||||
|
2. **Web-Support:** Re-Aktivierung des PKCE Flows für die Web-Variante.
|
||||||
|
3. **Members Feature:** Implementierung der echten Fachlogik für Mitgliederverwaltung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Fazit:** Das Fundament steht. Die App ist sicher und kommuniziert erfolgreich mit dem Backend.
|
||||||
48
docs/99_Journal/2026-01-22_Session_Log_Frontend_Auth.md
Normal file
48
docs/99_Journal/2026-01-22_Session_Log_Frontend_Auth.md
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
---
|
||||||
|
type: Journal
|
||||||
|
status: COMPLETED
|
||||||
|
owner: Frontend Expert
|
||||||
|
date: 2026-01-22
|
||||||
|
participants:
|
||||||
|
- Frontend Expert
|
||||||
|
- User
|
||||||
|
---
|
||||||
|
|
||||||
|
# Session Log: Frontend Auth & Refactoring
|
||||||
|
|
||||||
|
**Datum:** 22. Jänner 2026
|
||||||
|
**Ziel:** Implementierung des Login-Flows im Frontend und Refactoring der Architektur.
|
||||||
|
|
||||||
|
## Durchgeführte Arbeiten
|
||||||
|
|
||||||
|
### 1. Architektur-Refactoring
|
||||||
|
* **Auth-Feature:** Das Modul `frontend/features/auth-feature` wurde nach `frontend/core/auth` verschoben, da es sich um eine Basisfunktionalität (Infrastruktur) handelt.
|
||||||
|
* **Design-System:** Das Package `at.mocode.clients.shared.commonui` wurde zu `at.mocode.frontend.core.designsystem` refactored.
|
||||||
|
* **Cleanup:** Alte, redundante Dateien und Module wurden bereinigt.
|
||||||
|
|
||||||
|
### 2. Authentifizierung (Login)
|
||||||
|
* **Client:** Umstellung auf `postman-client` (Confidential Client) für den Desktop-Login, da `web-app` (Public Client) keine Direct Access Grants (Password Flow) unterstützte.
|
||||||
|
* **Secret:** Das Client Secret (`postman-secret-123`) wurde temporär in `AppConstants` hinterlegt (DEV-Only).
|
||||||
|
* **AuthApiClient:** Implementierung von Basic Auth Header für den Token-Request.
|
||||||
|
* **LoginViewModel:** Fix des State-Managements beim Logout (automatischer Reset von `isAuthenticated`).
|
||||||
|
|
||||||
|
### 3. UI & Navigation
|
||||||
|
* **MainApp:** Einführung von `AppScaffold` und Scroll-Support für Landing/Welcome Screens.
|
||||||
|
* **Navigation:** Hinzufügen von "Zurück"-Buttons in `LoginScreen` und `PingScreen`.
|
||||||
|
* **Usability:** Entfernung verwirrender Browser-Login-Buttons.
|
||||||
|
|
||||||
|
### 4. Backend-Integration
|
||||||
|
* **Secure Ping:** Erfolgreich getestet (200 OK mit Token).
|
||||||
|
* **Sync:** Erfolgreich getestet (200 OK mit Token). URL-Fix (`/api/pings/sync` -> `/api/ping/sync`).
|
||||||
|
|
||||||
|
## Ergebnisse
|
||||||
|
* Die Desktop-App ist nun voll funktionsfähig: Login, Logout, Secure API Calls und Sync funktionieren.
|
||||||
|
* Die Code-Struktur ist sauberer und folgt der Trennung zwischen Core (Infra) und Features (Domain).
|
||||||
|
|
||||||
|
## Offene Punkte
|
||||||
|
* **Browser-Login:** PKCE Flow für Web-Target muss noch sauber implementiert werden.
|
||||||
|
* **User-Info:** Das Profil zeigt noch "unbekannt", da der Username nicht korrekt aus dem Token geparst wird.
|
||||||
|
* **Secret Management:** Das Client Secret darf nicht im Code bleiben (für Prod).
|
||||||
|
|
||||||
|
---
|
||||||
|
**Status:** ✅ Erfolgreich abgeschlossen.
|
||||||
|
|
@ -1,10 +1,5 @@
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
/**
|
|
||||||
* Dieses Modul kapselt die gesamte UI und Logik für das Authentication-Feature.
|
|
||||||
* Es kennt seine eigenen technischen Abhängigkeiten (Ktor, Coroutines)
|
|
||||||
* und den UI-Baukasten (common-ui), aber es kennt keine anderen Features.
|
|
||||||
*/
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
alias(libs.plugins.composeMultiplatform)
|
alias(libs.plugins.composeMultiplatform)
|
||||||
|
|
@ -12,12 +7,10 @@ plugins {
|
||||||
alias(libs.plugins.kotlinSerialization)
|
alias(libs.plugins.kotlinSerialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "at.mocode.clients"
|
group = "at.mocode.frontend.core"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
// Toolchain is now handled centrally in the root build.gradle.kts
|
|
||||||
|
|
||||||
jvm()
|
jvm()
|
||||||
|
|
||||||
js {
|
js {
|
||||||
|
|
@ -28,26 +21,18 @@ kotlin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wasm vorerst deaktiviert
|
|
||||||
/*
|
|
||||||
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
|
||||||
wasmJs {
|
|
||||||
browser()
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
// UI Kit (Design System)
|
// UI Kit (Design System)
|
||||||
implementation(projects.frontend.core.designSystem)
|
implementation(projects.frontend.core.designSystem)
|
||||||
|
|
||||||
// Shared Konfig & Utilities (AppConfig + BuildConfig)
|
// Shared Konfig & Utilities
|
||||||
implementation(projects.frontend.shared)
|
implementation(projects.frontend.shared)
|
||||||
|
|
||||||
// Network core (provides apiClient + TokenProvider)
|
// Network core (provides apiClient + TokenProvider interface)
|
||||||
implementation(projects.frontend.core.network)
|
implementation(projects.frontend.core.network)
|
||||||
|
|
||||||
// Compose dependencies (Core UI)
|
// Compose dependencies
|
||||||
implementation(compose.runtime)
|
implementation(compose.runtime)
|
||||||
implementation(compose.foundation)
|
implementation(compose.foundation)
|
||||||
implementation(compose.material3)
|
implementation(compose.material3)
|
||||||
|
|
@ -55,10 +40,10 @@ kotlin {
|
||||||
implementation(compose.components.resources)
|
implementation(compose.components.resources)
|
||||||
implementation(compose.materialIconsExtended)
|
implementation(compose.materialIconsExtended)
|
||||||
|
|
||||||
// Bundles (Cleaned up dependencies)
|
// Bundles
|
||||||
implementation(libs.bundles.kmp.common) // Coroutines, Serialization, DateTime
|
implementation(libs.bundles.kmp.common)
|
||||||
implementation(libs.bundles.ktor.client.common) // Ktor Client (Core, Auth, JSON, Logging)
|
implementation(libs.bundles.ktor.client.common)
|
||||||
implementation(libs.bundles.compose.common) // ViewModel & Lifecycle
|
implementation(libs.bundles.compose.common)
|
||||||
|
|
||||||
// DI
|
// DI
|
||||||
implementation(libs.koin.core)
|
implementation(libs.koin.core)
|
||||||
|
|
@ -83,28 +68,14 @@ kotlin {
|
||||||
jsMain.dependencies {
|
jsMain.dependencies {
|
||||||
implementation(libs.ktor.client.js)
|
implementation(libs.ktor.client.js)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
val wasmJsMain = getByName("wasmJsMain")
|
|
||||||
wasmJsMain.dependencies {
|
|
||||||
implementation(libs.ktor.client.js) // WASM verwendet JS-Client [cite: 7]
|
|
||||||
|
|
||||||
// Compose für shared UI components für WASM
|
|
||||||
implementation(compose.runtime)
|
|
||||||
implementation(compose.foundation)
|
|
||||||
implementation(compose.material3)
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// KMP Compile-Optionen
|
|
||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
|
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(JvmTarget.JVM_25)
|
jvmTarget.set(JvmTarget.JVM_25)
|
||||||
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"
|
"-Xexpect-actual-classes"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package at.mocode.clients.authfeature
|
package at.mocode.frontend.core.auth.data
|
||||||
|
|
||||||
import at.mocode.shared.core.AppConstants
|
import at.mocode.shared.core.AppConstants
|
||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package at.mocode.clients.authfeature
|
package at.mocode.frontend.core.auth.data
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
package at.mocode.frontend.core.auth.di
|
||||||
|
|
||||||
|
import at.mocode.frontend.core.auth.data.AuthApiClient
|
||||||
|
import at.mocode.frontend.core.auth.data.AuthTokenManager
|
||||||
|
import at.mocode.frontend.core.auth.presentation.LoginViewModel
|
||||||
|
import at.mocode.frontend.core.network.TokenProvider
|
||||||
|
import at.mocode.shared.core.AppConstants
|
||||||
|
import org.koin.core.qualifier.named
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Koin module for core-auth: provides AuthTokenManager and binds it as TokenProvider for apiClient.
|
||||||
|
*/
|
||||||
|
val authModule = module {
|
||||||
|
// Single in-memory token manager
|
||||||
|
single { AuthTokenManager() }
|
||||||
|
|
||||||
|
// AuthApiClient with injected apiClient and DEV client secret
|
||||||
|
single {
|
||||||
|
AuthApiClient(
|
||||||
|
httpClient = get(named("apiClient")),
|
||||||
|
clientSecret = AppConstants.KEYCLOAK_CLIENT_SECRET
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginViewModel
|
||||||
|
factory { LoginViewModel(get(), get(), get(named("apiClient"))) }
|
||||||
|
|
||||||
|
// Bridge to core network TokenProvider without adding a hard dependency there
|
||||||
|
single<TokenProvider> {
|
||||||
|
object : TokenProvider {
|
||||||
|
override fun getAccessToken(): String? {
|
||||||
|
val token = get<AuthTokenManager>().getToken()
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
package at.mocode.frontend.core.auth.presentation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
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.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun LoginScreen(
|
||||||
|
viewModel: LoginViewModel,
|
||||||
|
onLoginSuccess: () -> Unit = {},
|
||||||
|
onBack: () -> Unit = {} // New callback for back navigation
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
val passwordFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Anmelden") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
// Username field
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.username,
|
||||||
|
onValueChange = viewModel::updateUsername,
|
||||||
|
label = { Text("Benutzername") },
|
||||||
|
enabled = !uiState.isLoading,
|
||||||
|
isError = uiState.usernameError != null,
|
||||||
|
supportingText = uiState.usernameError?.let { { Text(it) } },
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { passwordFocusRequester.requestFocus() }
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Password field
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.password,
|
||||||
|
onValueChange = viewModel::updatePassword,
|
||||||
|
label = { Text("Passwort") },
|
||||||
|
enabled = !uiState.isLoading,
|
||||||
|
isError = uiState.passwordError != null,
|
||||||
|
supportingText = uiState.passwordError?.let { { Text(it) } },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
if (uiState.canLogin) {
|
||||||
|
viewModel.login()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(passwordFocusRequester)
|
||||||
|
.padding(bottom = 24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
if (uiState.errorMessage != null) {
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = uiState.errorMessage!!,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login button
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.login() },
|
||||||
|
enabled = uiState.canLogin && !uiState.isLoading,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp)
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Anmelden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle login success
|
||||||
|
LaunchedEffect(uiState.isAuthenticated) {
|
||||||
|
if (uiState.isAuthenticated) {
|
||||||
|
onLoginSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
package at.mocode.clients.authfeature
|
package at.mocode.frontend.core.auth.presentation
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import at.mocode.frontend.core.auth.data.AuthApiClient
|
||||||
|
import at.mocode.frontend.core.auth.data.AuthTokenManager
|
||||||
import io.ktor.client.request.post
|
import io.ktor.client.request.post
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -37,6 +39,17 @@ class LoginViewModel(
|
||||||
private val _uiState = MutableStateFlow(LoginUiState())
|
private val _uiState = MutableStateFlow(LoginUiState())
|
||||||
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Observe AuthTokenManager state to keep UI in sync
|
||||||
|
viewModelScope.launch {
|
||||||
|
authTokenManager.authState.collect { authState ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isAuthenticated = authState.isAuthenticated
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun updateUsername(username: String) {
|
fun updateUsername(username: String) {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
username = username,
|
username = username,
|
||||||
|
|
@ -86,9 +99,10 @@ class LoginViewModel(
|
||||||
// Store the JWT token
|
// Store the JWT token
|
||||||
authTokenManager.setToken(loginResponse.token)
|
authTokenManager.setToken(loginResponse.token)
|
||||||
|
|
||||||
|
// isAuthenticated will be updated via the flow collector in init block
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isAuthenticated = true,
|
|
||||||
errorMessage = null
|
errorMessage = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -118,6 +132,7 @@ class LoginViewModel(
|
||||||
|
|
||||||
fun logout() {
|
fun logout() {
|
||||||
authTokenManager.clearToken()
|
authTokenManager.clearToken()
|
||||||
|
// Reset UI state (clear username/password)
|
||||||
_uiState.value = LoginUiState()
|
_uiState.value = LoginUiState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package at.mocode.clients.shared.commonui.components
|
package at.mocode.frontend.core.designsystem.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package at.mocode.clients.shared.commonui.components
|
package at.mocode.frontend.core.designsystem.components
|
||||||
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package at.mocode.clients.shared.commonui.components
|
package at.mocode.frontend.core.designsystem.components
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package at.mocode.clients.shared.commonui.components
|
package at.mocode.frontend.core.designsystem.components
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package at.mocode.clients.shared.commonui.components
|
package at.mocode.frontend.core.designsystem.components
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package at.mocode.clients.shared.commonui.components
|
package at.mocode.frontend.core.designsystem.components
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package at.mocode.clients.shared.commonui.components
|
package at.mocode.frontend.core.designsystem.components
|
||||||
|
|
||||||
// Legacy notification components removed due to dependency on old presentation layer.
|
// Legacy notification components removed due to dependency on old presentation layer.
|
||||||
// Intentionally left empty as part of cleanup. You can safely delete this file
|
// Intentionally left empty as part of cleanup. You can safely delete this file
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package at.mocode.clients.shared.commonui.theme
|
package at.mocode.frontend.core.designsystem.theme
|
||||||
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
package at.mocode.clients.authfeature
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun LoginScreen(
|
|
||||||
viewModel: LoginViewModel,
|
|
||||||
onLoginSuccess: () -> Unit = {}
|
|
||||||
) {
|
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
|
||||||
val passwordFocusRequester = remember { FocusRequester() }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
// Title
|
|
||||||
Text(
|
|
||||||
text = "Anmelden",
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = Modifier.padding(bottom = 32.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Username field
|
|
||||||
OutlinedTextField(
|
|
||||||
value = uiState.username,
|
|
||||||
onValueChange = viewModel::updateUsername,
|
|
||||||
label = { Text("Benutzername") },
|
|
||||||
enabled = !uiState.isLoading,
|
|
||||||
isError = uiState.usernameError != null,
|
|
||||||
supportingText = uiState.usernameError?.let { { Text(it) } },
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Text,
|
|
||||||
imeAction = ImeAction.Next
|
|
||||||
),
|
|
||||||
keyboardActions = KeyboardActions(
|
|
||||||
onNext = { passwordFocusRequester.requestFocus() }
|
|
||||||
),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(bottom = 16.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Password field
|
|
||||||
OutlinedTextField(
|
|
||||||
value = uiState.password,
|
|
||||||
onValueChange = viewModel::updatePassword,
|
|
||||||
label = { Text("Passwort") },
|
|
||||||
enabled = !uiState.isLoading,
|
|
||||||
isError = uiState.passwordError != null,
|
|
||||||
supportingText = uiState.passwordError?.let { { Text(it) } },
|
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Password,
|
|
||||||
imeAction = ImeAction.Done
|
|
||||||
),
|
|
||||||
keyboardActions = KeyboardActions(
|
|
||||||
onDone = {
|
|
||||||
if (uiState.canLogin) {
|
|
||||||
viewModel.login()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.focusRequester(passwordFocusRequester)
|
|
||||||
.padding(bottom = 24.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Error message
|
|
||||||
if (uiState.errorMessage != null) {
|
|
||||||
Card(
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
|
||||||
),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(bottom = 16.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = uiState.errorMessage!!,
|
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.padding(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login button
|
|
||||||
Button(
|
|
||||||
onClick = { viewModel.login() },
|
|
||||||
enabled = uiState.canLogin && !uiState.isLoading,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(48.dp)
|
|
||||||
) {
|
|
||||||
if (uiState.isLoading) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text("Anmelden")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle login success
|
|
||||||
LaunchedEffect(uiState.isAuthenticated) {
|
|
||||||
if (uiState.isAuthenticated) {
|
|
||||||
onLoginSuccess()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
package at.mocode.clients.authfeature.di
|
|
||||||
|
|
||||||
import at.mocode.clients.authfeature.AuthApiClient
|
|
||||||
import at.mocode.clients.authfeature.AuthTokenManager
|
|
||||||
import at.mocode.clients.authfeature.LoginViewModel
|
|
||||||
import at.mocode.frontend.core.network.TokenProvider
|
|
||||||
import org.koin.core.qualifier.named
|
|
||||||
import org.koin.dsl.module
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Koin module for auth-feature: provides AuthTokenManager and binds it as TokenProvider for apiClient.
|
|
||||||
*/
|
|
||||||
val authFeatureModule = module {
|
|
||||||
// Single in-memory token manager
|
|
||||||
single { AuthTokenManager() }
|
|
||||||
|
|
||||||
// AuthApiClient with injected apiClient
|
|
||||||
single { AuthApiClient(get(named("apiClient"))) }
|
|
||||||
|
|
||||||
// LoginViewModel
|
|
||||||
factory { LoginViewModel(get(), get(), get(named("apiClient"))) }
|
|
||||||
|
|
||||||
// Bridge to core network TokenProvider without adding a hard dependency there
|
|
||||||
single<TokenProvider> {
|
|
||||||
object : TokenProvider {
|
|
||||||
override fun getAccessToken(): String? {
|
|
||||||
val token = get<AuthTokenManager>().getToken()
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
package at.mocode.clients.authfeature.oauth
|
|
||||||
|
|
||||||
data class CallbackParams(val code: String, val state: String?)
|
|
||||||
|
|
||||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
|
||||||
expect object AuthCallbackParams {
|
|
||||||
/**
|
|
||||||
* Parse OAuth callback parameters from the current environment.
|
|
||||||
* - JS (web): reads window.location.search for `code` and `state` and removes them from the URL.
|
|
||||||
* - JVM (desktop): returns null.
|
|
||||||
*/
|
|
||||||
fun parse(): CallbackParams?
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
package at.mocode.clients.authfeature.oauth
|
|
||||||
|
|
||||||
import at.mocode.shared.core.AppConstants
|
|
||||||
|
|
||||||
data class PkceState(
|
|
||||||
val state: String,
|
|
||||||
val codeVerifier: String,
|
|
||||||
val codeChallenge: String,
|
|
||||||
val method: String = "S256"
|
|
||||||
)
|
|
||||||
|
|
||||||
object OAuthParams {
|
|
||||||
const val RESPONSE_TYPE = "code"
|
|
||||||
const val SCOPE = "openid"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* expect/actual service to support PKCE across JS and JVM.
|
|
||||||
* For the desktop (JVM) target we currently do not start a browser flow,
|
|
||||||
* but we provide hashing to keep API parity.
|
|
||||||
*/
|
|
||||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
|
||||||
expect object OAuthPkceService {
|
|
||||||
/** Starts a PKCE auth attempt and stores transient state in memory. */
|
|
||||||
suspend fun startAuth(): PkceState
|
|
||||||
|
|
||||||
/** Returns currently active state if any (not persisted). */
|
|
||||||
fun current(): PkceState?
|
|
||||||
|
|
||||||
/** Clears transient state (after success/failure). */
|
|
||||||
fun clear()
|
|
||||||
|
|
||||||
/** Builds the authorize URL for the current state. */
|
|
||||||
fun buildAuthorizeUrl(state: PkceState, redirectUri: String = AppConstants.webRedirectUri()): String
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
package at.mocode.clients.authfeature.oauth
|
|
||||||
|
|
||||||
import kotlinx.browser.window
|
|
||||||
|
|
||||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
|
||||||
actual object AuthCallbackParams {
|
|
||||||
actual fun parse(): CallbackParams? {
|
|
||||||
val search = window.location.search
|
|
||||||
if (search.isBlank()) return null
|
|
||||||
val params = js("new URLSearchParams(arguments[0])").unsafeCast<(String) -> dynamic>()(search)
|
|
||||||
val code = params.get("code") as String?
|
|
||||||
val state = params.get("state") as String?
|
|
||||||
return if (!code.isNullOrBlank()) {
|
|
||||||
// Clean up query params to avoid re-processing on recomposition
|
|
||||||
val url = window.location.origin + window.location.pathname
|
|
||||||
window.history.replaceState(null, "", url)
|
|
||||||
CallbackParams(code, state)
|
|
||||||
} else null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
package at.mocode.clients.authfeature.oauth
|
|
||||||
|
|
||||||
import at.mocode.shared.core.AppConstants
|
|
||||||
import kotlinx.browser.window
|
|
||||||
import kotlinx.coroutines.await
|
|
||||||
import org.khronos.webgl.ArrayBuffer
|
|
||||||
import org.khronos.webgl.Uint8Array
|
|
||||||
import kotlin.js.Promise
|
|
||||||
import kotlin.random.Random
|
|
||||||
|
|
||||||
private var currentPkce: PkceState? = null
|
|
||||||
|
|
||||||
private fun base64UrlFromBytes(bytes: ByteArray): String {
|
|
||||||
// Build binary string from bytes
|
|
||||||
val sb = StringBuilder(bytes.size)
|
|
||||||
for (b in bytes) sb.append(b.toInt().toChar())
|
|
||||||
val b64 = window.btoa(sb.toString())
|
|
||||||
return b64.replace("+", "-").replace("/", "_").trimEnd('=')
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun base64UrlFromArrayBuffer(buf: ArrayBuffer): String {
|
|
||||||
val arr = Uint8Array(buf)
|
|
||||||
var binary = ""
|
|
||||||
val len = arr.length
|
|
||||||
for (i in 0 until len) {
|
|
||||||
val v = (arr.asDynamic()[i] as Number).toInt()
|
|
||||||
binary += fromCharCode(v)
|
|
||||||
}
|
|
||||||
val b64 = window.btoa(binary)
|
|
||||||
return b64.replace("+", "-").replace("/", "_").trimEnd('=')
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun randomUrlSafe(length: Int): String {
|
|
||||||
val bytes = Random.nextBytes(length)
|
|
||||||
// Use base64url for entropy; ensure URL-safe by replacing padding removed already
|
|
||||||
return base64UrlFromBytes(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sha256(input: String): Promise<ArrayBuffer> {
|
|
||||||
val enc: dynamic = js("new TextEncoder()")
|
|
||||||
val data = enc.encode(input)
|
|
||||||
val subtle: dynamic = window.asDynamic().crypto.subtle
|
|
||||||
return subtle.digest("SHA-256", data) as Promise<ArrayBuffer>
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
|
||||||
actual object OAuthPkceService {
|
|
||||||
actual suspend fun startAuth(): PkceState {
|
|
||||||
val codeVerifier = randomUrlSafe(64)
|
|
||||||
val challengeBuf = sha256(codeVerifier).await()
|
|
||||||
val codeChallenge = base64UrlFromArrayBuffer(challengeBuf)
|
|
||||||
val state = randomUrlSafe(16)
|
|
||||||
val pkce = PkceState(state, codeVerifier, codeChallenge)
|
|
||||||
currentPkce = pkce
|
|
||||||
return pkce
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun current(): PkceState? = currentPkce
|
|
||||||
|
|
||||||
actual fun clear() {
|
|
||||||
currentPkce = null
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun buildAuthorizeUrl(state: PkceState, redirectUri: String): String {
|
|
||||||
val params = listOf(
|
|
||||||
"response_type" to OAuthParams.RESPONSE_TYPE,
|
|
||||||
"client_id" to AppConstants.KEYCLOAK_CLIENT_ID,
|
|
||||||
"redirect_uri" to redirectUri,
|
|
||||||
"scope" to OAuthParams.SCOPE,
|
|
||||||
"state" to state.state,
|
|
||||||
"code_challenge" to state.codeChallenge,
|
|
||||||
"code_challenge_method" to state.method
|
|
||||||
).joinToString("&") { (k, v) -> "$k=" + encodeURIComponent(v) }
|
|
||||||
return AppConstants.authorizeEndpoint() + "?" + params
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("UnsafeCastFromDynamic")
|
|
||||||
private fun encodeURIComponent(value: String): String = js("encodeURIComponent")(value)
|
|
||||||
|
|
||||||
@Suppress("UnsafeCastFromDynamic")
|
|
||||||
private fun fromCharCode(code: Int): String = js("String.fromCharCode")(code)
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
package at.mocode.clients.authfeature.oauth
|
|
||||||
|
|
||||||
actual object AuthCallbackParams {
|
|
||||||
actual fun parse(): CallbackParams? = null
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
package at.mocode.clients.authfeature.oauth
|
|
||||||
|
|
||||||
import at.mocode.shared.core.AppConstants
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.security.SecureRandom
|
|
||||||
import java.util.Base64
|
|
||||||
|
|
||||||
private var currentPkceJvm: PkceState? = null
|
|
||||||
|
|
||||||
private fun base64UrlNoPad(bytes: ByteArray): String =
|
|
||||||
Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
|
|
||||||
|
|
||||||
private fun randomUrlSafe(length: Int): String {
|
|
||||||
// Generate bytes and Base64 URL encode (will be > length due to encoding)
|
|
||||||
val rnd = SecureRandom()
|
|
||||||
val bytes = ByteArray(length)
|
|
||||||
rnd.nextBytes(bytes)
|
|
||||||
return base64UrlNoPad(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sha256Base64Url(input: String): String {
|
|
||||||
val md = MessageDigest.getInstance("SHA-256")
|
|
||||||
val digest = md.digest(input.toByteArray(Charsets.UTF_8))
|
|
||||||
return base64UrlNoPad(digest)
|
|
||||||
}
|
|
||||||
|
|
||||||
actual object OAuthPkceService {
|
|
||||||
actual suspend fun startAuth(): PkceState {
|
|
||||||
val codeVerifier = randomUrlSafe(64)
|
|
||||||
val codeChallenge = sha256Base64Url(codeVerifier)
|
|
||||||
val state = randomUrlSafe(16)
|
|
||||||
val pkce = PkceState(state, codeVerifier, codeChallenge)
|
|
||||||
currentPkceJvm = pkce
|
|
||||||
return pkce
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun current(): PkceState? = currentPkceJvm
|
|
||||||
|
|
||||||
actual fun clear() {
|
|
||||||
currentPkceJvm = null
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun buildAuthorizeUrl(state: PkceState, redirectUri: String): String {
|
|
||||||
val params = listOf(
|
|
||||||
"response_type" to OAuthParams.RESPONSE_TYPE,
|
|
||||||
"client_id" to AppConstants.KEYCLOAK_CLIENT_ID,
|
|
||||||
"redirect_uri" to redirectUri,
|
|
||||||
"scope" to OAuthParams.SCOPE,
|
|
||||||
"state" to state.state,
|
|
||||||
"code_challenge" to state.codeChallenge,
|
|
||||||
"code_challenge_method" to state.method
|
|
||||||
).joinToString("&") { (k, v) -> "$k=" + java.net.URLEncoder.encode(v, Charsets.UTF_8) }
|
|
||||||
return AppConstants.authorizeEndpoint() + "?" + params
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dieses Modul kapselt die gesamte UI und Logik für das Authentication-Feature.
|
|
||||||
* Es kennt seine eigenen technischen Abhängigkeiten (Ktor, Coroutines)
|
|
||||||
* und den UI-Baukasten (common-ui), aber es kennt keine anderen Features.
|
|
||||||
*/
|
|
||||||
plugins {
|
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
|
||||||
alias(libs.plugins.composeMultiplatform)
|
|
||||||
alias(libs.plugins.composeCompiler)
|
|
||||||
alias(libs.plugins.kotlinSerialization)
|
|
||||||
}
|
|
||||||
|
|
||||||
group = "at.mocode.clients"
|
|
||||||
version = "1.0.0"
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
// Toolchain is now handled centrally in the root build.gradle.kts
|
|
||||||
val enableWasm = providers.gradleProperty("enableWasm").orNull == "true"
|
|
||||||
|
|
||||||
jvm()
|
|
||||||
|
|
||||||
js {
|
|
||||||
browser {
|
|
||||||
testTask {
|
|
||||||
enabled = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
binaries.executable()
|
|
||||||
}
|
|
||||||
|
|
||||||
// WASM, nur wenn explizit aktiviert
|
|
||||||
if (enableWasm) {
|
|
||||||
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
|
||||||
wasmJs {
|
|
||||||
browser()
|
|
||||||
binaries.executable()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceSets {
|
|
||||||
commonMain.dependencies {
|
|
||||||
// UI Kit (Design System)
|
|
||||||
implementation(project(":frontend:core:design-system"))
|
|
||||||
|
|
||||||
// Shared Konfig & Utilities (AppConfig + BuildConfig)
|
|
||||||
implementation(project(":frontend:shared"))
|
|
||||||
|
|
||||||
// Compose dependencies
|
|
||||||
implementation(compose.runtime)
|
|
||||||
implementation(compose.foundation)
|
|
||||||
implementation(compose.material3)
|
|
||||||
implementation(compose.ui)
|
|
||||||
implementation(compose.components.resources)
|
|
||||||
implementation(compose.materialIconsExtended)
|
|
||||||
|
|
||||||
// Bundles (Cleaned up dependencies)
|
|
||||||
implementation(libs.bundles.kmp.common) // Coroutines, Serialization, DateTime
|
|
||||||
implementation(libs.bundles.ktor.client.common) // Ktor Client (Core, Auth, JSON, Logging)
|
|
||||||
implementation(libs.bundles.compose.common) // ViewModel & Lifecycle
|
|
||||||
}
|
|
||||||
|
|
||||||
commonTest.dependencies {
|
|
||||||
implementation(libs.kotlin.test)
|
|
||||||
implementation(libs.kotlinx.coroutines.test)
|
|
||||||
implementation(libs.ktor.client.mock)
|
|
||||||
}
|
|
||||||
|
|
||||||
jvmTest.dependencies {
|
|
||||||
implementation(libs.mockk)
|
|
||||||
implementation(projects.platform.platformTesting)
|
|
||||||
implementation(libs.bundles.testing.jvm)
|
|
||||||
}
|
|
||||||
|
|
||||||
jvmMain.dependencies {
|
|
||||||
implementation(libs.ktor.client.cio)
|
|
||||||
}
|
|
||||||
|
|
||||||
jsMain.dependencies {
|
|
||||||
implementation(libs.ktor.client.js)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WASM SourceSet, nur wenn aktiviert
|
|
||||||
if (enableWasm) {
|
|
||||||
val wasmJsMain = getByName("wasmJsMain")
|
|
||||||
wasmJsMain.dependencies {
|
|
||||||
implementation(libs.ktor.client.js) // WASM verwendet JS-Client [cite: 7]
|
|
||||||
|
|
||||||
// Compose für shared UI components für WASM
|
|
||||||
implementation(compose.runtime)
|
|
||||||
implementation(compose.foundation)
|
|
||||||
implementation(compose.material3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// KMP Compile-Optionen
|
|
||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
|
|
||||||
compilerOptions {
|
|
||||||
jvmTarget.set(JvmTarget.JVM_25)
|
|
||||||
freeCompilerArgs.addAll(
|
|
||||||
"-opt-in=kotlin.RequiresOptIn",
|
|
||||||
// Suppress beta warning for expect/actual classes as per project decision
|
|
||||||
"-Xexpect-actual-classes"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -20,6 +20,7 @@ class PingSyncServiceImpl(
|
||||||
) : PingSyncService {
|
) : PingSyncService {
|
||||||
|
|
||||||
override suspend fun syncPings() {
|
override suspend fun syncPings() {
|
||||||
syncManager.performSync(repository, "/api/pings/sync")
|
// Corrected endpoint: /api/ping/sync (singular)
|
||||||
|
syncManager.performSync(repository, "/api/ping/sync")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,121 +8,131 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun PingScreen(viewModel: PingViewModel) {
|
fun PingScreen(
|
||||||
|
viewModel: PingViewModel,
|
||||||
|
onBack: () -> Unit = {}
|
||||||
|
) {
|
||||||
val uiState = viewModel.uiState
|
val uiState = viewModel.uiState
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
Column(
|
Scaffold(
|
||||||
modifier = Modifier
|
topBar = {
|
||||||
.fillMaxSize()
|
TopAppBar(
|
||||||
.padding(16.dp)
|
title = { Text("Ping Service") },
|
||||||
.verticalScroll(scrollState),
|
navigationIcon = {
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
IconButton(onClick = onBack) {
|
||||||
) {
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
|
||||||
Text(
|
}
|
||||||
text = "Ping Service",
|
}
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
)
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
|
|
||||||
if (uiState.isLoading || uiState.isSyncing) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(16.dp)
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
if (uiState.isLoading || uiState.isSyncing) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
|
||||||
if (uiState.errorMessage != null) {
|
if (uiState.errorMessage != null) {
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = "Error",
|
text = "Error",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.error
|
color = MaterialTheme.colorScheme.error
|
||||||
)
|
)
|
||||||
Text(text = uiState.errorMessage)
|
Text(text = uiState.errorMessage)
|
||||||
Button(onClick = { viewModel.clearError() }) {
|
Button(onClick = { viewModel.clearError() }) {
|
||||||
Text("Clear")
|
Text("Clear")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (uiState.lastSyncResult != null) {
|
if (uiState.lastSyncResult != null) {
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = "Sync Status",
|
text = "Sync Status",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Text(text = uiState.lastSyncResult)
|
Text(text = uiState.lastSyncResult)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Button(onClick = { viewModel.performSimplePing() }) {
|
Button(onClick = { viewModel.performSimplePing() }) {
|
||||||
Text("Simple Ping")
|
Text("Simple Ping")
|
||||||
}
|
}
|
||||||
Button(onClick = { viewModel.performEnhancedPing() }) {
|
Button(onClick = { viewModel.performEnhancedPing() }) {
|
||||||
Text("Enhanced Ping")
|
Text("Enhanced Ping")
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Button(onClick = { viewModel.performHealthCheck() }) {
|
|
||||||
Text("Health Check")
|
|
||||||
}
|
|
||||||
Button(onClick = { viewModel.performSecurePing() }) {
|
|
||||||
Text("Secure Ping")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Button(onClick = { viewModel.triggerSync() }) {
|
|
||||||
Text("Sync Now")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uiState.simplePingResponse != null) {
|
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
|
||||||
Text("Simple / Secure Ping Response:", style = MaterialTheme.typography.titleMedium)
|
|
||||||
Text("Status: ${uiState.simplePingResponse.status}")
|
|
||||||
Text("Service: ${uiState.simplePingResponse.service}")
|
|
||||||
Text("Timestamp: ${uiState.simplePingResponse.timestamp}")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (uiState.enhancedPingResponse != null) {
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
Button(onClick = { viewModel.performHealthCheck() }) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Text("Health Check")
|
||||||
Text("Enhanced Ping Response:", style = MaterialTheme.typography.titleMedium)
|
}
|
||||||
Text("Status: ${uiState.enhancedPingResponse.status}")
|
Button(onClick = { viewModel.performSecurePing() }) {
|
||||||
Text("Timestamp: ${uiState.enhancedPingResponse.timestamp}")
|
Text("Secure Ping")
|
||||||
Text("Circuit Breaker: ${uiState.enhancedPingResponse.circuitBreakerState}")
|
|
||||||
Text("Response Time: ${uiState.enhancedPingResponse.responseTime}ms")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (uiState.healthResponse != null) {
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
Button(onClick = { viewModel.triggerSync() }) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Text("Sync Now")
|
||||||
Text("Health Response:", style = MaterialTheme.typography.titleMedium)
|
}
|
||||||
Text("Status: ${uiState.healthResponse.status}")
|
}
|
||||||
Text("Healthy: ${uiState.healthResponse.healthy}")
|
|
||||||
Text("Service: ${uiState.healthResponse.service}")
|
if (uiState.simplePingResponse != null) {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text("Simple / Secure Ping Response:", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text("Status: ${uiState.simplePingResponse.status}")
|
||||||
|
Text("Service: ${uiState.simplePingResponse.service}")
|
||||||
|
Text("Timestamp: ${uiState.simplePingResponse.timestamp}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.enhancedPingResponse != null) {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text("Enhanced Ping Response:", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text("Status: ${uiState.enhancedPingResponse.status}")
|
||||||
|
Text("Timestamp: ${uiState.enhancedPingResponse.timestamp}")
|
||||||
|
Text("Circuit Breaker: ${uiState.enhancedPingResponse.circuitBreakerState}")
|
||||||
|
Text("Response Time: ${uiState.enhancedPingResponse.responseTime}ms")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.healthResponse != null) {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text("Health Response:", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text("Status: ${uiState.healthResponse.status}")
|
||||||
|
Text("Healthy: ${uiState.healthResponse.healthy}")
|
||||||
|
Text("Service: ${uiState.healthResponse.service}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,22 +12,28 @@ object AppConstants {
|
||||||
const val KEYCLOAK_URL: String = "http://localhost:8180"
|
const val KEYCLOAK_URL: String = "http://localhost:8180"
|
||||||
const val KEYCLOAK_REALM: String = "meldestelle"
|
const val KEYCLOAK_REALM: String = "meldestelle"
|
||||||
|
|
||||||
// Use public client configured in realm import: `web-app`
|
// Use 'postman-client' for Desktop App Password Flow (Direct Access Grants enabled)
|
||||||
const val KEYCLOAK_CLIENT_ID: String = "web-app"
|
// 'web-app' is for Browser Flow (PKCE)
|
||||||
|
const val KEYCLOAK_CLIENT_ID: String = "postman-client"
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
const val KEYCLOAK_CLIENT_SECRET: String = "postman-secret-123"
|
||||||
|
|
||||||
// Default redirect URI for web PKCE flow (served by Nginx in web image)
|
// 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=...
|
// We use the root path so Keycloak can redirect back to /?code=...
|
||||||
fun webRedirectUri(): String = "http://localhost:4000/"
|
fun webRedirectUri(): String = "http://localhost:4000/"
|
||||||
|
|
||||||
fun registerUrl(): String =
|
fun registerUrl(): String =
|
||||||
"$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/registrations?client_id=$KEYCLOAK_CLIENT_ID&response_type=code&redirect_uri=${
|
"$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/registrations?client_id=web-app&response_type=code&redirect_uri=${
|
||||||
encode(
|
encode(
|
||||||
webRedirectUri()
|
webRedirectUri()
|
||||||
)
|
)
|
||||||
}"
|
}"
|
||||||
|
|
||||||
fun loginUrl(): String =
|
fun loginUrl(): String =
|
||||||
"$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/auth?client_id=$KEYCLOAK_CLIENT_ID&response_type=code&redirect_uri=${
|
"$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/auth?client_id=web-app&response_type=code&redirect_uri=${
|
||||||
encode(
|
encode(
|
||||||
webRedirectUri()
|
webRedirectUri()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -80,8 +80,8 @@ kotlin {
|
||||||
implementation(projects.frontend.core.navigation)
|
implementation(projects.frontend.core.navigation)
|
||||||
implementation(projects.frontend.core.network)
|
implementation(projects.frontend.core.network)
|
||||||
implementation(projects.frontend.core.sync)
|
implementation(projects.frontend.core.sync)
|
||||||
implementation(project(":frontend:core:local-db"))
|
implementation(projects.frontend.core.localDb)
|
||||||
implementation(projects.frontend.features.authFeature)
|
implementation(projects.frontend.core.auth)
|
||||||
implementation(projects.frontend.features.pingFeature)
|
implementation(projects.frontend.features.pingFeature)
|
||||||
|
|
||||||
// DI (Koin) needed to call initKoin { modules(...) }
|
// DI (Koin) needed to call initKoin { modules(...) }
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,23 @@
|
||||||
import androidx.compose.material3.*
|
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.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import at.mocode.clients.shared.navigation.AppScreen
|
import at.mocode.clients.shared.navigation.AppScreen
|
||||||
import at.mocode.clients.authfeature.AuthTokenManager
|
import at.mocode.frontend.core.auth.data.AuthTokenManager
|
||||||
|
import at.mocode.frontend.core.auth.data.AuthApiClient
|
||||||
|
import at.mocode.frontend.core.auth.presentation.LoginScreen
|
||||||
|
import at.mocode.frontend.core.auth.presentation.LoginViewModel
|
||||||
import at.mocode.ping.feature.presentation.PingScreen
|
import at.mocode.ping.feature.presentation.PingScreen
|
||||||
import at.mocode.ping.feature.presentation.PingViewModel
|
import at.mocode.ping.feature.presentation.PingViewModel
|
||||||
import at.mocode.shared.core.AppConstants
|
import at.mocode.shared.core.AppConstants
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import at.mocode.frontend.core.designsystem.components.AppFooter
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import at.mocode.clients.authfeature.AuthApiClient
|
|
||||||
import at.mocode.clients.authfeature.LoginViewModel
|
|
||||||
import at.mocode.clients.authfeature.oauth.OAuthPkceService
|
|
||||||
import at.mocode.clients.authfeature.oauth.AuthCallbackParams
|
|
||||||
import at.mocode.clients.authfeature.oauth.CallbackParams
|
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
|
|
@ -33,29 +32,10 @@ fun MainApp() {
|
||||||
|
|
||||||
// Resolve AuthTokenManager from Koin
|
// Resolve AuthTokenManager from Koin
|
||||||
val authTokenManager = koinInject<AuthTokenManager>()
|
val authTokenManager = koinInject<AuthTokenManager>()
|
||||||
val authApiClient = koinInject<AuthApiClient>()
|
|
||||||
// Delta-Sync blueprint: resolve the Ping feature view model via Koin.
|
// Delta-Sync blueprint: resolve the Ping feature view model via Koin.
|
||||||
val pingViewModel: PingViewModel = koinViewModel()
|
val pingViewModel: PingViewModel = koinViewModel()
|
||||||
// scope removed (unused)
|
val loginViewModel: LoginViewModel = koinViewModel()
|
||||||
|
|
||||||
// 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 res = authApiClient.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.Landing -> LandingScreen(
|
is AppScreen.Landing -> LandingScreen(
|
||||||
|
|
@ -65,18 +45,21 @@ fun MainApp() {
|
||||||
is AppScreen.Home -> WelcomeScreen(
|
is AppScreen.Home -> WelcomeScreen(
|
||||||
authTokenManager = authTokenManager,
|
authTokenManager = authTokenManager,
|
||||||
onOpenPing = { currentScreen = AppScreen.Ping },
|
onOpenPing = { currentScreen = AppScreen.Ping },
|
||||||
onOpenLogin = {
|
onOpenLogin = { currentScreen = AppScreen.Login },
|
||||||
// Fallback to the local LoginScreen (Password Grant) if PKCE cannot be started
|
|
||||||
currentScreen = AppScreen.Login
|
|
||||||
},
|
|
||||||
onOpenProfile = { currentScreen = AppScreen.Profile }
|
onOpenProfile = { currentScreen = AppScreen.Profile }
|
||||||
)
|
)
|
||||||
|
|
||||||
is AppScreen.Login -> LoginScreen(
|
is AppScreen.Login -> LoginScreen(
|
||||||
onLoginSuccess = { currentScreen = AppScreen.Profile }
|
viewModel = loginViewModel,
|
||||||
|
onLoginSuccess = { currentScreen = AppScreen.Profile },
|
||||||
|
onBack = { currentScreen = AppScreen.Home }
|
||||||
|
)
|
||||||
|
|
||||||
|
is AppScreen.Ping -> PingScreen(
|
||||||
|
viewModel = pingViewModel,
|
||||||
|
onBack = { currentScreen = AppScreen.Home } // Navigate back to Home
|
||||||
)
|
)
|
||||||
|
|
||||||
is AppScreen.Ping -> PingScreen(viewModel = pingViewModel)
|
|
||||||
is AppScreen.Profile -> AuthStatusScreen(
|
is AppScreen.Profile -> AuthStatusScreen(
|
||||||
authTokenManager = authTokenManager,
|
authTokenManager = authTokenManager,
|
||||||
onBackToHome = { currentScreen = AppScreen.Home }
|
onBackToHome = { currentScreen = AppScreen.Home }
|
||||||
|
|
@ -93,8 +76,13 @@ private fun LandingScreen(
|
||||||
onPrimaryCta: () -> Unit,
|
onPrimaryCta: () -> Unit,
|
||||||
onSecondary: () -> Unit
|
onSecondary: () -> Unit
|
||||||
) {
|
) {
|
||||||
// Minimal Landing-Layout: Hero → Manifest → Features → Footer
|
val scrollState = rememberScrollState()
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(scrollState)
|
||||||
|
) {
|
||||||
// Hero
|
// Hero
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -102,7 +90,6 @@ private fun LandingScreen(
|
||||||
.padding(horizontal = 24.dp, vertical = 40.dp),
|
.padding(horizontal = 24.dp, vertical = 40.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
// Wortmarke (Platzhalter)
|
|
||||||
Text(
|
Text(
|
||||||
text = "Equest‑Events",
|
text = "Equest‑Events",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
|
@ -179,7 +166,7 @@ private fun LandingScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Footer
|
// Footer
|
||||||
at.mocode.clients.shared.commonui.components.AppFooter()
|
AppFooter()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,12 +194,12 @@ private fun WelcomeScreen(
|
||||||
onOpenProfile: () -> Unit
|
onOpenProfile: () -> Unit
|
||||||
) {
|
) {
|
||||||
val authState by authTokenManager.authState.collectAsState()
|
val authState by authTokenManager.authState.collectAsState()
|
||||||
val uriHandler = LocalUriHandler.current
|
val scrollState = rememberScrollState()
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.verticalScroll(scrollState)
|
||||||
.padding(24.dp),
|
.padding(24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
|
|
@ -239,41 +226,11 @@ private fun WelcomeScreen(
|
||||||
Button(onClick = onOpenPing, modifier = Modifier.weight(1f)) { Text("Ping-Service") }
|
Button(onClick = onOpenPing, modifier = Modifier.weight(1f)) { Text("Ping-Service") }
|
||||||
if (!authState.isAuthenticated) {
|
if (!authState.isAuthenticated) {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = onOpenLogin,
|
||||||
// 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)
|
modifier = Modifier.weight(1f)
|
||||||
) { Text("Login") }
|
) { Text("Login") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.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") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -299,6 +256,9 @@ private fun AuthStatusScreen(
|
||||||
authTokenManager.clearToken()
|
authTokenManager.clearToken()
|
||||||
onBackToHome()
|
onBackToHome()
|
||||||
}) { Text("Abmelden") }
|
}) { Text("Abmelden") }
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedButton(onClick = onBackToHome) { Text("Zurück zur Startseite") }
|
||||||
} else {
|
} else {
|
||||||
Text("Nicht angemeldet.")
|
Text("Nicht angemeldet.")
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
@ -308,64 +268,3 @@ private fun AuthStatusScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun LoginScreen(
|
|
||||||
onLoginSuccess: () -> Unit
|
|
||||||
) {
|
|
||||||
val viewModel = koinViewModel<LoginViewModel>()
|
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
|
||||||
|
|
||||||
LaunchedEffect(uiState.isAuthenticated) {
|
|
||||||
if (uiState.isAuthenticated) {
|
|
||||||
onLoginSuccess()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
Text("Anmeldung", style = MaterialTheme.typography.headlineMedium)
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = uiState.username,
|
|
||||||
onValueChange = { viewModel.updateUsername(it) },
|
|
||||||
label = { Text("Benutzername") },
|
|
||||||
singleLine = true,
|
|
||||||
enabled = !uiState.isLoading,
|
|
||||||
isError = uiState.usernameError != null,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
if (uiState.usernameError != null) {
|
|
||||||
Text(uiState.usernameError!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = uiState.password,
|
|
||||||
onValueChange = { viewModel.updatePassword(it) },
|
|
||||||
label = { Text("Passwort") },
|
|
||||||
singleLine = true,
|
|
||||||
enabled = !uiState.isLoading,
|
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
|
||||||
isError = uiState.passwordError != null,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
if (uiState.passwordError != null) {
|
|
||||||
Text(uiState.passwordError!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uiState.errorMessage != null) {
|
|
||||||
Text(uiState.errorMessage!!, color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
Button(
|
|
||||||
onClick = { viewModel.login() },
|
|
||||||
enabled = uiState.canLogin
|
|
||||||
) { Text(if (uiState.isLoading) "Bitte warten…" else "Login") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
package navigation
|
package navigation
|
||||||
|
|
||||||
import at.mocode.clients.authfeature.AuthTokenManager
|
import at.mocode.frontend.core.auth.data.AuthTokenManager
|
||||||
import at.mocode.frontend.core.domain.models.User
|
import at.mocode.frontend.core.domain.models.User
|
||||||
import at.mocode.frontend.core.navigation.CurrentUserProvider
|
import at.mocode.frontend.core.navigation.CurrentUserProvider
|
||||||
import at.mocode.frontend.core.navigation.DeepLinkHandler
|
import at.mocode.frontend.core.navigation.DeepLinkHandler
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
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.authfeature.di.authFeatureModule
|
import at.mocode.frontend.core.auth.di.authModule
|
||||||
import at.mocode.frontend.core.localdb.AppDatabase
|
import at.mocode.frontend.core.localdb.AppDatabase
|
||||||
import at.mocode.frontend.core.localdb.DatabaseProvider
|
import at.mocode.frontend.core.localdb.DatabaseProvider
|
||||||
import at.mocode.frontend.core.localdb.localDbModule
|
import at.mocode.frontend.core.localdb.localDbModule
|
||||||
|
|
@ -27,8 +27,8 @@ fun main() {
|
||||||
// Initialize DI (Koin) with shared modules + network + local DB modules
|
// Initialize DI (Koin) with shared modules + network + local DB modules
|
||||||
try {
|
try {
|
||||||
// Updated: Only load the consolidated pingFeatureModule from at.mocode.ping.feature.di
|
// Updated: Only load the consolidated pingFeatureModule from at.mocode.ping.feature.di
|
||||||
initKoin { modules(networkModule, localDbModule, syncModule, pingFeatureModule, authFeatureModule, navigationModule) }
|
initKoin { modules(networkModule, localDbModule, syncModule, pingFeatureModule, authModule, navigationModule) }
|
||||||
console.log("[WebApp] Koin initialized with networkModule + localDbModule + authFeatureModule + navigationModule + pingFeatureModule")
|
console.log("[WebApp] Koin initialized with networkModule + localDbModule + authModule + navigationModule + pingFeatureModule")
|
||||||
} catch (e: dynamic) {
|
} catch (e: dynamic) {
|
||||||
console.warn("[WebApp] Koin initialization warning:", e)
|
console.warn("[WebApp] Koin initialization warning:", e)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import androidx.compose.ui.window.WindowState
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import at.mocode.shared.di.initKoin
|
import at.mocode.shared.di.initKoin
|
||||||
import at.mocode.frontend.core.network.networkModule
|
import at.mocode.frontend.core.network.networkModule
|
||||||
import at.mocode.clients.authfeature.di.authFeatureModule
|
import at.mocode.frontend.core.auth.di.authModule
|
||||||
import at.mocode.frontend.core.sync.di.syncModule
|
import at.mocode.frontend.core.sync.di.syncModule
|
||||||
import at.mocode.ping.feature.di.pingFeatureModule
|
import at.mocode.ping.feature.di.pingFeatureModule
|
||||||
import at.mocode.frontend.core.localdb.AppDatabase
|
import at.mocode.frontend.core.localdb.AppDatabase
|
||||||
|
|
@ -19,8 +19,8 @@ fun main() = application {
|
||||||
// Initialize DI (Koin) with shared modules + network module
|
// Initialize DI (Koin) with shared modules + network module
|
||||||
try {
|
try {
|
||||||
// Updated: Only load the consolidated pingFeatureModule from at.mocode.ping.feature.di
|
// Updated: Only load the consolidated pingFeatureModule from at.mocode.ping.feature.di
|
||||||
initKoin { modules(networkModule, syncModule, pingFeatureModule, authFeatureModule, navigationModule, localDbModule) }
|
initKoin { modules(networkModule, syncModule, pingFeatureModule, authModule, navigationModule, localDbModule) }
|
||||||
println("[DesktopApp] Koin initialized with networkModule + authFeatureModule + navigationModule + pingFeatureModule + localDbModule")
|
println("[DesktopApp] Koin initialized with networkModule + authModule + navigationModule + pingFeatureModule + localDbModule")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("[DesktopApp] Koin initialization warning: ${e.message}")
|
println("[DesktopApp] Koin initialization warning: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,8 @@ include(":docs")
|
||||||
// FRONTEND
|
// FRONTEND
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// --- CORE ---
|
// --- CORE ---
|
||||||
|
// frontend/core/auth
|
||||||
|
include(":frontend:core:auth") // MOVED from features
|
||||||
include(":frontend:core:domain")
|
include(":frontend:core:domain")
|
||||||
include(":frontend:core:design-system")
|
include(":frontend:core:design-system")
|
||||||
include(":frontend:core:navigation")
|
include(":frontend:core:navigation")
|
||||||
|
|
@ -137,7 +139,6 @@ include(":frontend:core:local-db")
|
||||||
include(":frontend:core:sync")
|
include(":frontend:core:sync")
|
||||||
|
|
||||||
// --- FEATURES ---
|
// --- FEATURES ---
|
||||||
include(":frontend:features:auth-feature")
|
|
||||||
// include(":frontend:features:members-feature")
|
// include(":frontend:features:members-feature")
|
||||||
include(":frontend:features:ping-feature")
|
include(":frontend:features:ping-feature")
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user