MP-24 Epic 4: Fertigstellung von MP-24: Authentication DI Refactoring & Cleanup
Das Refactoring der Authentifizierungs-Komponenten auf Dependency Injection (Koin) wurde verifiziert und abgeschlossen. Alle manuellen Instanziierungen wurden entfernt und die korrekte Initialisierung in allen Entry-Points sichergestellt.
This commit is contained in:
parent
7d9d729d7d
commit
afd109efcc
|
|
@ -164,6 +164,8 @@ tasks.register("archGuardForbiddenAuthorizationHeader") {
|
||||||
"setHeader(\"Authorization\"",
|
"setHeader(\"Authorization\"",
|
||||||
"headers[\"Authorization\"]",
|
"headers[\"Authorization\"]",
|
||||||
"headers[\'Authorization\']",
|
"headers[\'Authorization\']",
|
||||||
|
".header(HttpHeaders.Authorization",
|
||||||
|
"header(HttpHeaders.Authorization",
|
||||||
)
|
)
|
||||||
// Scope: Frontend-only enforcement. Backend/Test code is excluded.
|
// Scope: Frontend-only enforcement. Backend/Test code is excluded.
|
||||||
val srcDirs = listOf("clients", "frontend")
|
val srcDirs = listOf("clients", "frontend")
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import io.ktor.client.request.forms.*
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import org.koin.core.context.GlobalContext
|
|
||||||
import org.koin.core.qualifier.named
|
import org.koin.core.qualifier.named
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -31,6 +30,7 @@ data class LoginResponse(
|
||||||
* HTTP client for authentication API calls
|
* HTTP client for authentication API calls
|
||||||
*/
|
*/
|
||||||
class AuthApiClient(
|
class AuthApiClient(
|
||||||
|
private val httpClient: HttpClient,
|
||||||
// Keycloak Basis-URL (z. B. http://localhost:8180)
|
// Keycloak Basis-URL (z. B. http://localhost:8180)
|
||||||
private val keycloakBaseUrl: String = AppConstants.KEYCLOAK_URL,
|
private val keycloakBaseUrl: String = AppConstants.KEYCLOAK_URL,
|
||||||
// Realm-Name in Keycloak
|
// Realm-Name in Keycloak
|
||||||
|
|
@ -40,7 +40,6 @@ class AuthApiClient(
|
||||||
// Optional: Client-Secret (nur bei vertraulichen Clients erforderlich)
|
// Optional: Client-Secret (nur bei vertraulichen Clients erforderlich)
|
||||||
private val clientSecret: String? = null
|
private val clientSecret: String? = null
|
||||||
) {
|
) {
|
||||||
private val client: HttpClient by lazy { GlobalContext.get().koin.get<HttpClient>(named("apiClient")) }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticate user with username and password
|
* Authenticate user with username and password
|
||||||
|
|
@ -48,7 +47,7 @@ class AuthApiClient(
|
||||||
suspend fun login(username: String, password: String): LoginResponse {
|
suspend fun login(username: String, password: String): LoginResponse {
|
||||||
val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token"
|
val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token"
|
||||||
return try {
|
return try {
|
||||||
val response = client.submitForm(
|
val response = httpClient.submitForm(
|
||||||
url = tokenEndpoint,
|
url = tokenEndpoint,
|
||||||
formParameters = Parameters.build {
|
formParameters = Parameters.build {
|
||||||
append("grant_type", "password")
|
append("grant_type", "password")
|
||||||
|
|
@ -93,7 +92,7 @@ class AuthApiClient(
|
||||||
suspend fun exchangeAuthorizationCode(code: String, codeVerifier: String, redirectUri: String): LoginResponse {
|
suspend fun exchangeAuthorizationCode(code: String, codeVerifier: String, redirectUri: String): LoginResponse {
|
||||||
val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token"
|
val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token"
|
||||||
return try {
|
return try {
|
||||||
val response = client.submitForm(
|
val response = httpClient.submitForm(
|
||||||
url = tokenEndpoint,
|
url = tokenEndpoint,
|
||||||
formParameters = Parameters.build {
|
formParameters = Parameters.build {
|
||||||
append("grant_type", "authorization_code")
|
append("grant_type", "authorization_code")
|
||||||
|
|
@ -136,7 +135,7 @@ class AuthApiClient(
|
||||||
suspend fun refreshToken(refreshToken: String): LoginResponse {
|
suspend fun refreshToken(refreshToken: String): LoginResponse {
|
||||||
val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token"
|
val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token"
|
||||||
return try {
|
return try {
|
||||||
val response = client.submitForm(
|
val response = httpClient.submitForm(
|
||||||
url = tokenEndpoint,
|
url = tokenEndpoint,
|
||||||
formParameters = Parameters.build {
|
formParameters = Parameters.build {
|
||||||
append("grant_type", "refresh_token")
|
append("grant_type", "refresh_token")
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
package at.mocode.clients.authfeature
|
|
||||||
|
|
||||||
import at.mocode.shared.core.AppConstants
|
|
||||||
import io.ktor.client.*
|
|
||||||
import io.ktor.client.plugins.contentnegotiation.*
|
|
||||||
import io.ktor.client.request.*
|
|
||||||
import io.ktor.http.*
|
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Singleton object for managing authenticated HTTP client configuration.
|
|
||||||
* Provides methods to create HTTP clients and add authentication headers manually.
|
|
||||||
*/
|
|
||||||
object AuthenticatedHttpClient {
|
|
||||||
|
|
||||||
private val authTokenManager = AuthTokenManager()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a basic HTTP client with JSON support
|
|
||||||
*/
|
|
||||||
fun create(baseUrl: String = AppConstants.GATEWAY_URL): HttpClient {
|
|
||||||
return HttpClient {
|
|
||||||
install(ContentNegotiation) {
|
|
||||||
json(Json {
|
|
||||||
prettyPrint = true
|
|
||||||
isLenient = true
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add an authentication header to an HTTP request builder if a token is available
|
|
||||||
*/
|
|
||||||
fun HttpRequestBuilder.addAuthHeader() {
|
|
||||||
authTokenManager.getBearerToken()?.let { bearerToken ->
|
|
||||||
header(HttpHeaders.Authorization, bearerToken)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the shared AuthTokenManager instance
|
|
||||||
*/
|
|
||||||
fun getAuthTokenManager(): AuthTokenManager = authTokenManager
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an HTTP client without authentication (for login/public endpoints)
|
|
||||||
*/
|
|
||||||
fun createUnauthenticated(): HttpClient {
|
|
||||||
return HttpClient {
|
|
||||||
install(ContentNegotiation) {
|
|
||||||
json(Json {
|
|
||||||
prettyPrint = true
|
|
||||||
isLenient = true
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -14,13 +14,11 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginScreen(
|
fun LoginScreen(
|
||||||
authTokenManager: AuthTokenManager,
|
viewModel: LoginViewModel,
|
||||||
viewModel: LoginViewModel = viewModel { LoginViewModel(authTokenManager) },
|
|
||||||
onLoginSuccess: () -> Unit = {}
|
onLoginSuccess: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@ package at.mocode.clients.authfeature
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import io.ktor.client.request.post
|
import io.ktor.client.request.post
|
||||||
import org.koin.core.context.GlobalContext
|
|
||||||
import org.koin.core.qualifier.named
|
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -31,15 +29,14 @@ data class LoginUiState(
|
||||||
* ViewModel for handling login authentication logic
|
* ViewModel for handling login authentication logic
|
||||||
*/
|
*/
|
||||||
class LoginViewModel(
|
class LoginViewModel(
|
||||||
private val authTokenManager: AuthTokenManager
|
private val authTokenManager: AuthTokenManager,
|
||||||
|
private val authApiClient: AuthApiClient,
|
||||||
|
private val apiClient: HttpClient
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(LoginUiState())
|
private val _uiState = MutableStateFlow(LoginUiState())
|
||||||
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
private val authApiClient = AuthApiClient()
|
|
||||||
private val apiClient: HttpClient by lazy { GlobalContext.get().koin.get<HttpClient>(named("apiClient")) }
|
|
||||||
|
|
||||||
fun updateUsername(username: String) {
|
fun updateUsername(username: String) {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
username = username,
|
username = username,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
package at.mocode.clients.authfeature.di
|
package at.mocode.clients.authfeature.di
|
||||||
|
|
||||||
|
import at.mocode.clients.authfeature.AuthApiClient
|
||||||
import at.mocode.clients.authfeature.AuthTokenManager
|
import at.mocode.clients.authfeature.AuthTokenManager
|
||||||
|
import at.mocode.clients.authfeature.LoginViewModel
|
||||||
import at.mocode.frontend.core.network.TokenProvider
|
import at.mocode.frontend.core.network.TokenProvider
|
||||||
|
import org.koin.core.qualifier.named
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -11,6 +14,12 @@ val authFeatureModule = module {
|
||||||
// Single in-memory token manager
|
// Single in-memory token manager
|
||||||
single { AuthTokenManager() }
|
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
|
// Bridge to core network TokenProvider without adding a hard dependency there
|
||||||
single<TokenProvider> {
|
single<TokenProvider> {
|
||||||
object : TokenProvider {
|
object : TokenProvider {
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,8 @@ kotlin {
|
||||||
|
|
||||||
// DI (Koin) needed to call initKoin { modules(...) }
|
// DI (Koin) needed to call initKoin { modules(...) }
|
||||||
implementation(libs.koin.core)
|
implementation(libs.koin.core)
|
||||||
|
implementation(libs.koin.compose)
|
||||||
|
implementation(libs.koin.compose.viewmodel)
|
||||||
|
|
||||||
// Compose Multiplatform
|
// Compose Multiplatform
|
||||||
implementation(compose.runtime)
|
implementation(compose.runtime)
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ 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.clients.authfeature.AuthTokenManager
|
||||||
import org.koin.core.context.GlobalContext
|
|
||||||
import at.mocode.clients.pingfeature.PingScreen
|
import at.mocode.clients.pingfeature.PingScreen
|
||||||
import at.mocode.clients.pingfeature.PingViewModel
|
import at.mocode.clients.pingfeature.PingViewModel
|
||||||
import at.mocode.shared.core.AppConstants
|
import at.mocode.shared.core.AppConstants
|
||||||
|
|
@ -16,9 +15,12 @@ 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.AuthApiClient
|
||||||
|
import at.mocode.clients.authfeature.LoginViewModel
|
||||||
import at.mocode.clients.authfeature.oauth.OAuthPkceService
|
import at.mocode.clients.authfeature.oauth.OAuthPkceService
|
||||||
import at.mocode.clients.authfeature.oauth.AuthCallbackParams
|
import at.mocode.clients.authfeature.oauth.AuthCallbackParams
|
||||||
import at.mocode.clients.authfeature.oauth.CallbackParams
|
import at.mocode.clients.authfeature.oauth.CallbackParams
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainApp() {
|
fun MainApp() {
|
||||||
|
|
@ -30,7 +32,8 @@ fun MainApp() {
|
||||||
var currentScreen by remember { mutableStateOf<AppScreen>(AppScreen.Home) }
|
var currentScreen by remember { mutableStateOf<AppScreen>(AppScreen.Home) }
|
||||||
|
|
||||||
// Resolve AuthTokenManager from Koin
|
// Resolve AuthTokenManager from Koin
|
||||||
val authTokenManager = remember { GlobalContext.get().koin.get<AuthTokenManager>() }
|
val authTokenManager = koinInject<AuthTokenManager>()
|
||||||
|
val authApiClient = koinInject<AuthApiClient>()
|
||||||
val pingViewModel = remember { PingViewModel() }
|
val pingViewModel = remember { PingViewModel() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
|
@ -42,8 +45,7 @@ fun MainApp() {
|
||||||
val state = callback.state
|
val state = callback.state
|
||||||
val pkce = OAuthPkceService.current()
|
val pkce = OAuthPkceService.current()
|
||||||
if (pkce != null && pkce.state == state) {
|
if (pkce != null && pkce.state == state) {
|
||||||
val api = AuthApiClient()
|
val res = authApiClient.exchangeAuthorizationCode(code, pkce.codeVerifier, AppConstants.webRedirectUri())
|
||||||
val res = api.exchangeAuthorizationCode(code, pkce.codeVerifier, AppConstants.webRedirectUri())
|
|
||||||
val token = res.token
|
val token = res.token
|
||||||
if (res.success && token != null) {
|
if (res.success && token != null) {
|
||||||
authTokenManager.setToken(token)
|
authTokenManager.setToken(token)
|
||||||
|
|
@ -66,7 +68,6 @@ fun MainApp() {
|
||||||
)
|
)
|
||||||
|
|
||||||
is AppScreen.Login -> LoginScreen(
|
is AppScreen.Login -> LoginScreen(
|
||||||
authTokenManager = authTokenManager,
|
|
||||||
onLoginSuccess = { currentScreen = AppScreen.Profile }
|
onLoginSuccess = { currentScreen = AppScreen.Profile }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -194,15 +195,16 @@ private fun AuthStatusScreen(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LoginScreen(
|
private fun LoginScreen(
|
||||||
authTokenManager: AuthTokenManager,
|
|
||||||
onLoginSuccess: () -> Unit
|
onLoginSuccess: () -> Unit
|
||||||
) {
|
) {
|
||||||
var username by remember { mutableStateOf("") }
|
val viewModel = koinViewModel<LoginViewModel>()
|
||||||
var password by remember { mutableStateOf("") }
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
|
||||||
var isLoading by remember { mutableStateOf(false) }
|
LaunchedEffect(uiState.isAuthenticated) {
|
||||||
val scope = rememberCoroutineScope()
|
if (uiState.isAuthenticated) {
|
||||||
val api = remember { AuthApiClient() }
|
onLoginSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -213,48 +215,41 @@ private fun LoginScreen(
|
||||||
Text("Anmeldung", style = MaterialTheme.typography.headlineMedium)
|
Text("Anmeldung", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = username,
|
value = uiState.username,
|
||||||
onValueChange = { username = it },
|
onValueChange = { viewModel.updateUsername(it) },
|
||||||
label = { Text("Benutzername") },
|
label = { Text("Benutzername") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
enabled = !isLoading,
|
enabled = !uiState.isLoading,
|
||||||
|
isError = uiState.usernameError != null,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
if (uiState.usernameError != null) {
|
||||||
|
Text(uiState.usernameError!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = password,
|
value = uiState.password,
|
||||||
onValueChange = { password = it },
|
onValueChange = { viewModel.updatePassword(it) },
|
||||||
label = { Text("Passwort") },
|
label = { Text("Passwort") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
enabled = !isLoading,
|
enabled = !uiState.isLoading,
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
isError = uiState.passwordError != null,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
if (uiState.passwordError != null) {
|
||||||
|
Text(uiState.passwordError!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
|
||||||
error?.let {
|
if (uiState.errorMessage != null) {
|
||||||
Text(it, color = MaterialTheme.colorScheme.error)
|
Text(uiState.errorMessage!!, color = MaterialTheme.colorScheme.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = { viewModel.login() },
|
||||||
error = null
|
enabled = uiState.canLogin
|
||||||
isLoading = true
|
) { Text(if (uiState.isLoading) "Bitte warten…" else "Login") }
|
||||||
scope.launch {
|
|
||||||
val res = api.login(username.trim(), password)
|
|
||||||
val token = res.token
|
|
||||||
if (res.success && token != null) {
|
|
||||||
authTokenManager.setToken(token)
|
|
||||||
isLoading = false
|
|
||||||
onLoginSuccess()
|
|
||||||
} else {
|
|
||||||
isLoading = false
|
|
||||||
error = res.message ?: "Login fehlgeschlagen"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = !isLoading && username.isNotBlank() && password.isNotBlank()
|
|
||||||
) { Text(if (isLoading) "Bitte warten…" else "Login") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,20 @@ import androidx.compose.ui.window.ComposeViewport
|
||||||
import kotlinx.browser.document
|
import kotlinx.browser.document
|
||||||
import org.w3c.dom.HTMLElement
|
import org.w3c.dom.HTMLElement
|
||||||
|
|
||||||
|
import at.mocode.shared.di.initKoin
|
||||||
|
import at.mocode.frontend.core.network.networkModule
|
||||||
|
import at.mocode.clients.authfeature.di.authFeatureModule
|
||||||
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
fun main() {
|
fun main() {
|
||||||
|
// Initialize DI
|
||||||
|
try {
|
||||||
|
initKoin { modules(networkModule, authFeatureModule) }
|
||||||
|
println("[WasmApp] Koin initialized")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("[WasmApp] Koin init failed: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
val root = document.getElementById("ComposeTarget") as HTMLElement
|
val root = document.getElementById("ComposeTarget") as HTMLElement
|
||||||
ComposeViewport(root) {
|
ComposeViewport(root) {
|
||||||
MainApp()
|
MainApp()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user