feat(MP-29): navigation core module, auth guards & shell wiring\n\n- Establish :frontend:core:navigation module with DeepLinkHandler\n- Introduce NavigationPort & CurrentUserProvider (DI)\n- Harden admin routes against AppRoles.ADMIN\n- Wire Koin in JS/JVM/Wasm shells (navigationModule)\n- Remove legacy DeepLinkHandler from shared\n- Add unit tests for guard logic\n\nRef: MP-29 (#24)
This commit is contained in:
+43
-35
@@ -222,14 +222,15 @@ tasks.register("archGuardNoFeatureToFeatureDeps") {
|
|||||||
.forEach { cfg ->
|
.forEach { cfg ->
|
||||||
cfg.dependencies.withType(org.gradle.api.artifacts.ProjectDependency::class.java).forEach { dep ->
|
cfg.dependencies.withType(org.gradle.api.artifacts.ProjectDependency::class.java).forEach { dep ->
|
||||||
// Use reflection to avoid compile-time issues with dependencyProject property
|
// Use reflection to avoid compile-time issues with dependencyProject property
|
||||||
val proj = try {
|
val proj =
|
||||||
dep.javaClass.getMethod("getDependencyProject").invoke(dep) as org.gradle.api.Project
|
try {
|
||||||
} catch (e: Throwable) {
|
dep.javaClass.getMethod("getDependencyProject").invoke(dep) as org.gradle.api.Project
|
||||||
null
|
} catch (e: Throwable) {
|
||||||
}
|
null
|
||||||
|
}
|
||||||
val target = proj?.path ?: ""
|
val target = proj?.path ?: ""
|
||||||
if (target.startsWith(featurePrefix) && target != p.path) {
|
if (target.startsWith(featurePrefix) && target != p.path) {
|
||||||
violations += "${p.path} -> ${target} (configuration: ${cfg.name})"
|
violations += "${p.path} -> $target (configuration: ${cfg.name})"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,12 +238,13 @@ tasks.register("archGuardNoFeatureToFeatureDeps") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (violations.isNotEmpty()) {
|
if (violations.isNotEmpty()) {
|
||||||
val msg = buildString {
|
val msg =
|
||||||
appendLine("Feature isolation violation(s) detected:")
|
buildString {
|
||||||
violations.forEach { appendLine(" - $it") }
|
appendLine("Feature isolation violation(s) detected:")
|
||||||
appendLine()
|
violations.forEach { appendLine(" - $it") }
|
||||||
appendLine("Policy: frontend features must not depend on other features. Use navigation/shared domain in :frontend:core instead.")
|
appendLine()
|
||||||
}
|
appendLine("Policy: frontend features must not depend on other features. Use navigation/shared domain in :frontend:core instead.")
|
||||||
|
}
|
||||||
throw GradleException(msg)
|
throw GradleException(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,12 +253,13 @@ tasks.register("archGuardNoFeatureToFeatureDeps") {
|
|||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Bundle Size Budgets for Frontend Shells (Kotlin/JS)
|
// Bundle Size Budgets for Frontend Shells (Kotlin/JS)
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
// ✅ FIX: Klasse auf Top-Level verschieben
|
||||||
|
data class Budget(val rawBytes: Long, val gzipBytes: Long)
|
||||||
|
|
||||||
tasks.register("checkBundleBudget") {
|
tasks.register("checkBundleBudget") {
|
||||||
group = "verification"
|
group = "verification"
|
||||||
description = "Checks JS bundle sizes of frontend shells against configured budgets"
|
description = "Checks JS bundle sizes of frontend shells against configured budgets"
|
||||||
doLast {
|
doLast {
|
||||||
data class Budget(val rawBytes: Long, val gzipBytes: Long)
|
|
||||||
|
|
||||||
val budgetsFile = file("config/bundles/budgets.json")
|
val budgetsFile = file("config/bundles/budgets.json")
|
||||||
if (!budgetsFile.exists()) {
|
if (!budgetsFile.exists()) {
|
||||||
throw GradleException("Budgets file not found: ${budgetsFile.path}")
|
throw GradleException("Budgets file not found: ${budgetsFile.path}")
|
||||||
@@ -264,14 +267,16 @@ tasks.register("checkBundleBudget") {
|
|||||||
|
|
||||||
// Load budgets JSON as simple Map<String, Budget>
|
// Load budgets JSON as simple Map<String, Budget>
|
||||||
val text = budgetsFile.readText()
|
val text = budgetsFile.readText()
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val parsed =
|
val parsed =
|
||||||
groovy.json.JsonSlurper().parseText(text) as Map<String, Map<String, Any?>>
|
groovy.json.JsonSlurper().parseText(text) as Map<String, Map<String, Any?>>
|
||||||
val budgets = parsed.mapValues { (_, v) ->
|
val budgets =
|
||||||
val raw = (v["rawBytes"] as Number).toLong()
|
parsed.mapValues { (_, v) ->
|
||||||
val gz = (v["gzipBytes"] as Number).toLong()
|
val raw = (v["rawBytes"] as Number).toLong()
|
||||||
Budget(raw, gz)
|
val gz = (v["gzipBytes"] as Number).toLong()
|
||||||
}
|
Budget(raw, gz)
|
||||||
|
}
|
||||||
|
|
||||||
fun gzipSize(bytes: ByteArray): Long {
|
fun gzipSize(bytes: ByteArray): Long {
|
||||||
val baos = java.io.ByteArrayOutputStream()
|
val baos = java.io.ByteArrayOutputStream()
|
||||||
@@ -293,12 +298,14 @@ tasks.register("checkBundleBudget") {
|
|||||||
shells.forEach { shell ->
|
shells.forEach { shell ->
|
||||||
val key = shell.path.trimStart(':').replace(':', '/') // or use colon form for budgets keys below
|
val key = shell.path.trimStart(':').replace(':', '/') // or use colon form for budgets keys below
|
||||||
val colonKey = shell.path.trimStart(':').replace('/', ':').trim() // ensure ":a:b:c"
|
val colonKey = shell.path.trimStart(':').replace('/', ':').trim() // ensure ":a:b:c"
|
||||||
// Budgets are keyed by Gradle path with colons but without leading colon in config for readability
|
// Budgets are keyed by a Gradle path with colons but without leading colon in config for readability
|
||||||
val budgetKeyCandidates = listOf(
|
val budgetKeyCandidates =
|
||||||
shell.path.removePrefix(":"), // e.g., frontend:shells:meldestelle-portal
|
listOf(
|
||||||
colonKey.removePrefix(":"),
|
// e.g., frontend:shells:meldestelle-portal
|
||||||
shell.name,
|
shell.path.removePrefix(":"),
|
||||||
)
|
colonKey.removePrefix(":"),
|
||||||
|
shell.name,
|
||||||
|
)
|
||||||
|
|
||||||
val budgetEntry = budgetKeyCandidates.asSequence().mapNotNull { budgets[it] }.firstOrNull()
|
val budgetEntry = budgetKeyCandidates.asSequence().mapNotNull { budgets[it] }.firstOrNull()
|
||||||
if (budgetEntry == null) {
|
if (budgetEntry == null) {
|
||||||
@@ -334,13 +341,13 @@ tasks.register("checkBundleBudget") {
|
|||||||
val top = topFiles.sortedByDescending { it.second }.take(5)
|
val top = topFiles.sortedByDescending { it.second }.take(5)
|
||||||
|
|
||||||
report.appendLine("- ${shell.path}:")
|
report.appendLine("- ${shell.path}:")
|
||||||
report.appendLine(" raw: ${totalRaw} bytes (budget ${budgetEntry.rawBytes})")
|
report.appendLine(" raw: $totalRaw bytes (budget ${budgetEntry.rawBytes})")
|
||||||
report.appendLine(" gzip: ${totalGzip} bytes (budget ${budgetEntry.gzipBytes})")
|
report.appendLine(" gzip: $totalGzip bytes (budget ${budgetEntry.gzipBytes})")
|
||||||
report.appendLine(" top files:")
|
report.appendLine(" top files:")
|
||||||
top.forEach { (n, s) -> report.appendLine(" - $n: ${s} bytes") }
|
top.forEach { (n, s) -> report.appendLine(" - $n: $s bytes") }
|
||||||
|
|
||||||
if (totalRaw > budgetEntry.rawBytes || totalGzip > budgetEntry.gzipBytes) {
|
if (totalRaw > budgetEntry.rawBytes || totalGzip > budgetEntry.gzipBytes) {
|
||||||
errors += "${shell.path}: raw=${totalRaw}/${budgetEntry.rawBytes}, gzip=${totalGzip}/${budgetEntry.gzipBytes}"
|
errors += "${shell.path}: raw=$totalRaw/${budgetEntry.rawBytes}, gzip=$totalGzip/${budgetEntry.gzipBytes}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,12 +356,13 @@ tasks.register("checkBundleBudget") {
|
|||||||
file(outDir.resolve("bundle-budgets.txt")).writeText(report.toString())
|
file(outDir.resolve("bundle-budgets.txt")).writeText(report.toString())
|
||||||
|
|
||||||
if (errors.isNotEmpty()) {
|
if (errors.isNotEmpty()) {
|
||||||
val msg = buildString {
|
val msg =
|
||||||
appendLine("Bundle budget violations:")
|
buildString {
|
||||||
errors.forEach { appendLine(" - $it") }
|
appendLine("Bundle budget violations:")
|
||||||
appendLine()
|
errors.forEach { appendLine(" - $it") }
|
||||||
appendLine("See report: ${outDir.resolve("bundle-budgets.txt").path}")
|
appendLine()
|
||||||
}
|
appendLine("See report: ${outDir.resolve("bundle-budgets.txt").path}")
|
||||||
|
}
|
||||||
throw GradleException(msg)
|
throw GradleException(msg)
|
||||||
} else {
|
} else {
|
||||||
println(report.toString())
|
println(report.toString())
|
||||||
|
|||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package at.mocode.frontend.core.domain.models
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application-wide role constants used for authorization checks.
|
||||||
|
*/
|
||||||
|
object AppRoles {
|
||||||
|
const val ADMIN: String = "admin"
|
||||||
|
}
|
||||||
@@ -29,7 +29,8 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
// No specific dependencies needed for navigation routes
|
// Depend on core domain for User/Role types used by navigation API
|
||||||
|
implementation(project(":frontend:core:domain"))
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(libs.kotlin.test)
|
implementation(libs.kotlin.test)
|
||||||
|
|||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
package at.mocode.frontend.core.navigation
|
||||||
|
|
||||||
|
import at.mocode.frontend.core.domain.models.User
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstraction to obtain the current authenticated user (or null if guest).
|
||||||
|
* Implementations live in shells/apps and provide access to the actual auth state.
|
||||||
|
*/
|
||||||
|
interface CurrentUserProvider {
|
||||||
|
fun getCurrentUser(): User?
|
||||||
|
}
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
package at.mocode.frontend.core.navigation
|
||||||
|
|
||||||
|
import at.mocode.frontend.core.domain.models.AppRoles
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep link handling with minimal auth-aware guard via CurrentUserProvider.
|
||||||
|
* This version is self-contained in core:navigation and has no dependency on shared app store.
|
||||||
|
*/
|
||||||
|
class DeepLinkHandler(
|
||||||
|
private val navigation: NavigationPort,
|
||||||
|
private val currentUserProvider: CurrentUserProvider,
|
||||||
|
) {
|
||||||
|
|
||||||
|
data class DeepLinkConfig(
|
||||||
|
val scheme: String = "meldestelle",
|
||||||
|
val host: String = "app",
|
||||||
|
val allowedDomains: Set<String> = setOf("meldestelle.com", "localhost"),
|
||||||
|
val loginRoute: String = Routes.Auth.LOGIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val config = DeepLinkConfig()
|
||||||
|
|
||||||
|
fun handleDeepLink(url: String): Boolean {
|
||||||
|
val parsed = parseDeepLink(url) ?: return false
|
||||||
|
return processDeepLink(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processDeepLink(deepLink: DeepLink): Boolean {
|
||||||
|
val route = cleanRoute(deepLink.route)
|
||||||
|
|
||||||
|
// If route requires auth and user is missing → redirect to login
|
||||||
|
if (requiresAuth(route)) {
|
||||||
|
val user = currentUserProvider.getCurrentUser()
|
||||||
|
if (user == null) {
|
||||||
|
navigation.navigateTo(config.loginRoute)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Admin section guard: requires ADMIN role
|
||||||
|
if (requiresAdmin(route)) {
|
||||||
|
val isAdmin = user.roles.contains(AppRoles.ADMIN)
|
||||||
|
if (!isAdmin) {
|
||||||
|
navigation.navigateTo(Routes.HOME)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
navigation.navigateTo(deepLink.route)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDeepLink(url: String): DeepLink? {
|
||||||
|
return when {
|
||||||
|
url.startsWith("${config.scheme}://") -> parseCustomScheme(url)
|
||||||
|
url.startsWith("https://") || url.startsWith("http://") -> parseWeb(url)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseCustomScheme(url: String): DeepLink? {
|
||||||
|
val withoutScheme = url.removePrefix("${config.scheme}://")
|
||||||
|
val parts = withoutScheme.split("/")
|
||||||
|
if (parts.isEmpty() || parts[0] != config.host) return null
|
||||||
|
val path = "/" + parts.drop(1).joinToString("/")
|
||||||
|
val route = if (path.isBlank()) Routes.HOME else path
|
||||||
|
return DeepLink(DeepLinkType.CUSTOM_SCHEME, route, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseWeb(url: String): DeepLink? {
|
||||||
|
val urlParts = url.split("/")
|
||||||
|
if (urlParts.size < 3) return null
|
||||||
|
val domain = urlParts[2]
|
||||||
|
if (!config.allowedDomains.contains(domain)) return null
|
||||||
|
val path = "/" + urlParts.drop(3).joinToString("/")
|
||||||
|
val route = if (path.isBlank() || path == "/") Routes.HOME else path
|
||||||
|
return DeepLink(DeepLinkType.WEB_LINK, route, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cleanRoute(route: String): String = route.substringBefore("?")
|
||||||
|
|
||||||
|
private fun requiresAuth(route: String): Boolean {
|
||||||
|
if (route == Routes.HOME) return false
|
||||||
|
if (route == Routes.LOGIN || route == Routes.Auth.LOGIN) return false
|
||||||
|
if (route.startsWith("/auth/") && route != Routes.Auth.LOGIN) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requiresAdmin(route: String): Boolean = route.startsWith("${Routes.Admin.ROOT}/")
|
||||||
|
|
||||||
|
fun generateDeepLink(route: String, useCustomScheme: Boolean = true): String =
|
||||||
|
if (useCustomScheme) "${config.scheme}://${config.host}$route" else "https://${config.allowedDomains.first()}$route"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
package at.mocode.frontend.core.navigation
|
||||||
|
|
||||||
|
data class DeepLink(
|
||||||
|
val type: DeepLinkType,
|
||||||
|
val route: String,
|
||||||
|
val originalUrl: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class DeepLinkType {
|
||||||
|
CUSTOM_SCHEME,
|
||||||
|
WEB_LINK,
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package at.mocode.frontend.core.navigation
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal navigation abstraction used by core navigation components.
|
||||||
|
* The actual implementation lives in shells/apps and delegates to the app's router.
|
||||||
|
*/
|
||||||
|
interface NavigationPort {
|
||||||
|
fun navigateTo(route: String)
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package at.mocode.frontend.core.navigation
|
||||||
|
|
||||||
|
object Routes {
|
||||||
|
const val HOME = "/"
|
||||||
|
const val LOGIN = "/login"
|
||||||
|
|
||||||
|
object Auth {
|
||||||
|
const val LOGIN = "/auth/login"
|
||||||
|
}
|
||||||
|
|
||||||
|
object Admin {
|
||||||
|
const val ROOT = "/admin"
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
package at.mocode.frontend.core.navigation
|
||||||
|
|
||||||
|
import at.mocode.frontend.core.domain.models.AppRoles
|
||||||
|
import at.mocode.frontend.core.domain.models.User
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
private class FakeNav : NavigationPort {
|
||||||
|
var last: String? = null
|
||||||
|
override fun navigateTo(route: String) { last = route }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeUserProvider(private val user: User?) : CurrentUserProvider {
|
||||||
|
override fun getCurrentUser(): User? = user
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeepLinkHandlerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAdminRouteRejectedForGuest() {
|
||||||
|
val nav = FakeNav()
|
||||||
|
val provider = FakeUserProvider(null) // guest
|
||||||
|
val handler = DeepLinkHandler(nav, provider)
|
||||||
|
|
||||||
|
val ok = handler.handleDeepLink("https://meldestelle.com/admin/dashboard")
|
||||||
|
|
||||||
|
assertTrue(ok)
|
||||||
|
assertEquals(Routes.Auth.LOGIN, nav.last, "Guest must be redirected to login for admin route")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAdminRouteAllowedForAdmin() {
|
||||||
|
val nav = FakeNav()
|
||||||
|
val adminUser = User(id = "1", username = "admin", roles = listOf(AppRoles.ADMIN))
|
||||||
|
val provider = FakeUserProvider(adminUser)
|
||||||
|
val handler = DeepLinkHandler(nav, provider)
|
||||||
|
|
||||||
|
val ok = handler.handleDeepLink("https://meldestelle.com/admin/settings")
|
||||||
|
|
||||||
|
assertTrue(ok)
|
||||||
|
assertEquals("/admin/settings", nav.last, "Admin must be allowed to navigate to admin route")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
package at.mocode.shared.navigation
|
|
||||||
|
|
||||||
import at.mocode.shared.presentation.store.AppStore
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deep link handling for the application
|
|
||||||
*/
|
|
||||||
class DeepLinkHandler(
|
|
||||||
private val navigationManager: NavigationManager,
|
|
||||||
private val store: AppStore
|
|
||||||
) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deep link configuration
|
|
||||||
*/
|
|
||||||
data class DeepLinkConfig(
|
|
||||||
val scheme: String = "meldestelle",
|
|
||||||
val host: String = "app",
|
|
||||||
val allowedDomains: Set<String> = setOf("meldestelle.com", "localhost")
|
|
||||||
)
|
|
||||||
|
|
||||||
private val config = DeepLinkConfig()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle a deep link URL
|
|
||||||
*/
|
|
||||||
fun handleDeepLink(url: String): Boolean {
|
|
||||||
return try {
|
|
||||||
val parsedLink = parseDeepLink(url)
|
|
||||||
if (parsedLink != null) {
|
|
||||||
processDeepLink(parsedLink)
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Log error in real implementation
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse deep link URL into components
|
|
||||||
*/
|
|
||||||
private fun parseDeepLink(url: String): DeepLink? {
|
|
||||||
return when {
|
|
||||||
url.startsWith("${config.scheme}://") -> parseCustomSchemeLink(url)
|
|
||||||
url.startsWith("https://") || url.startsWith("http://") -> parseWebLink(url)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse custom scheme deep links (e.g., meldestelle://app/dashboard)
|
|
||||||
*/
|
|
||||||
private fun parseCustomSchemeLink(url: String): DeepLink? {
|
|
||||||
val withoutScheme = url.removePrefix("${config.scheme}://")
|
|
||||||
val parts = withoutScheme.split("/")
|
|
||||||
|
|
||||||
if (parts.isEmpty() || parts[0] != config.host) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
val path = "/" + parts.drop(1).joinToString("/")
|
|
||||||
val route = if (path == "/") Routes.HOME else path
|
|
||||||
|
|
||||||
return DeepLink(
|
|
||||||
type = DeepLinkType.CUSTOM_SCHEME,
|
|
||||||
route = route,
|
|
||||||
params = RouteUtils.parseRouteParams(route),
|
|
||||||
originalUrl = url
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse web deep links (e.g., https://meldestelle.com/dashboard)
|
|
||||||
*/
|
|
||||||
private fun parseWebLink(url: String): DeepLink? {
|
|
||||||
// Simple URL parsing - in real implementation use proper URL parser
|
|
||||||
val urlParts = url.split("/")
|
|
||||||
if (urlParts.size < 3) return null
|
|
||||||
|
|
||||||
val domain = urlParts[2]
|
|
||||||
if (!config.allowedDomains.contains(domain)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
val path = "/" + urlParts.drop(3).joinToString("/")
|
|
||||||
val route = if (path == "/" || path.isEmpty()) Routes.HOME else path
|
|
||||||
|
|
||||||
return DeepLink(
|
|
||||||
type = DeepLinkType.WEB_LINK,
|
|
||||||
route = route,
|
|
||||||
params = RouteUtils.parseRouteParams(route),
|
|
||||||
originalUrl = url
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process a parsed deep link
|
|
||||||
*/
|
|
||||||
private fun processDeepLink(deepLink: DeepLink) {
|
|
||||||
val authState = store.state.value.auth
|
|
||||||
val cleanRoute = RouteUtils.getCleanRoute(deepLink.route)
|
|
||||||
|
|
||||||
// Check if route requires authentication
|
|
||||||
if (RouteUtils.requiresAuth(cleanRoute)) {
|
|
||||||
if (!authState.isAuthenticated) {
|
|
||||||
// Save the intended route and redirect to log in
|
|
||||||
saveIntendedRoute(deepLink.route)
|
|
||||||
navigationManager.navigateTo(Routes.Auth.LOGIN)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if route requires admin privileges
|
|
||||||
if (RouteUtils.requiresAdmin(cleanRoute)) {
|
|
||||||
val hasAdminRole = authState.user?.roles?.contains("admin") ?: false
|
|
||||||
if (!hasAdminRole) {
|
|
||||||
// Redirect to unauthorized or home
|
|
||||||
navigationManager.navigateTo(Routes.HOME)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate to the route
|
|
||||||
navigationManager.navigateTo(deepLink.route)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save the intended route for after authentication
|
|
||||||
*/
|
|
||||||
private fun saveIntendedRoute(route: String) {
|
|
||||||
// In real implementation, save to persistent storage
|
|
||||||
// For now; we'll store it in a simple variable
|
|
||||||
intendedRoute = route
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get and clear the intended route
|
|
||||||
*/
|
|
||||||
fun getAndClearIntendedRoute(): String? {
|
|
||||||
val route = intendedRoute
|
|
||||||
intendedRoute = null
|
|
||||||
return route
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if there's a pending intended route
|
|
||||||
*/
|
|
||||||
fun hasIntendedRoute(): Boolean = intendedRoute != null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a deep link for a route
|
|
||||||
*/
|
|
||||||
fun generateDeepLink(route: String, useCustomScheme: Boolean = true): String {
|
|
||||||
return if (useCustomScheme) {
|
|
||||||
"${config.scheme}://${config.host}$route"
|
|
||||||
} else {
|
|
||||||
"https://${config.allowedDomains.first()}$route"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate if a route is valid for deep linking
|
|
||||||
*/
|
|
||||||
fun isValidDeepLinkRoute(route: String): Boolean {
|
|
||||||
return RouteUtils.isValidRoute(route) &&
|
|
||||||
!route.startsWith("/auth/") && // Auth routes shouldn't be deep linked
|
|
||||||
route != Routes.Auth.LOGIN
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private var intendedRoute: String? = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deep link data class
|
|
||||||
*/
|
|
||||||
data class DeepLink(
|
|
||||||
val type: DeepLinkType,
|
|
||||||
val route: String,
|
|
||||||
val params: Map<String, String>,
|
|
||||||
val originalUrl: String
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Types of deep links
|
|
||||||
*/
|
|
||||||
enum class DeepLinkType {
|
|
||||||
CUSTOM_SCHEME, // meldestelle://app/route
|
|
||||||
WEB_LINK // https://meldestelle.com/route
|
|
||||||
}
|
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
package navigation
|
||||||
|
|
||||||
|
import at.mocode.clients.authfeature.AuthTokenManager
|
||||||
|
import at.mocode.frontend.core.domain.models.User
|
||||||
|
import at.mocode.frontend.core.navigation.CurrentUserProvider
|
||||||
|
import at.mocode.frontend.core.navigation.DeepLinkHandler
|
||||||
|
import at.mocode.frontend.core.navigation.NavigationPort
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
class ShellCurrentUserProvider(
|
||||||
|
private val authTokenManager: AuthTokenManager,
|
||||||
|
) : CurrentUserProvider {
|
||||||
|
override fun getCurrentUser(): User? {
|
||||||
|
val state = authTokenManager.authState.value
|
||||||
|
if (!state.isAuthenticated) return null
|
||||||
|
// Roles are not yet modeled in AuthState; provide empty list for now
|
||||||
|
return User(
|
||||||
|
id = state.userId ?: state.username ?: "unknown",
|
||||||
|
username = state.username ?: state.userId ?: "unknown",
|
||||||
|
displayName = null,
|
||||||
|
roles = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NoopNavigationPort : NavigationPort {
|
||||||
|
var lastRoute: String? = null
|
||||||
|
override fun navigateTo(route: String) {
|
||||||
|
lastRoute = route
|
||||||
|
// Simple logging; actual routing is handled elsewhere in the shell
|
||||||
|
println("[NavigationPort] navigateTo $route")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val navigationModule = module {
|
||||||
|
single<CurrentUserProvider> { ShellCurrentUserProvider(get()) }
|
||||||
|
single<NavigationPort> { NoopNavigationPort() }
|
||||||
|
single { DeepLinkHandler(get(), get()) }
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import at.mocode.frontend.core.network.networkModule
|
|||||||
import at.mocode.clients.authfeature.di.authFeatureModule
|
import at.mocode.clients.authfeature.di.authFeatureModule
|
||||||
import at.mocode.frontend.core.localdb.localDbModule
|
import at.mocode.frontend.core.localdb.localDbModule
|
||||||
import at.mocode.frontend.core.localdb.DatabaseProvider
|
import at.mocode.frontend.core.localdb.DatabaseProvider
|
||||||
|
import navigation.navigationModule
|
||||||
import kotlinx.coroutines.MainScope
|
import kotlinx.coroutines.MainScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.core.context.GlobalContext
|
import org.koin.core.context.GlobalContext
|
||||||
@@ -20,8 +21,8 @@ fun main() {
|
|||||||
console.log("[WebApp] main() entered")
|
console.log("[WebApp] main() entered")
|
||||||
// Initialize DI (Koin) with shared modules + network + local DB modules
|
// Initialize DI (Koin) with shared modules + network + local DB modules
|
||||||
try {
|
try {
|
||||||
initKoin { modules(networkModule, localDbModule, authFeatureModule) }
|
initKoin { modules(networkModule, localDbModule, authFeatureModule, navigationModule) }
|
||||||
console.log("[WebApp] Koin initialized with networkModule + localDbModule + authFeatureModule")
|
console.log("[WebApp] Koin initialized with networkModule + localDbModule + authFeatureModule + navigationModule")
|
||||||
} catch (e: dynamic) {
|
} catch (e: dynamic) {
|
||||||
console.warn("[WebApp] Koin initialization warning:", e)
|
console.warn("[WebApp] Koin initialization warning:", e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ 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.clients.authfeature.di.authFeatureModule
|
||||||
|
import navigation.navigationModule
|
||||||
|
|
||||||
fun main() = application {
|
fun main() = application {
|
||||||
// Initialize DI (Koin) with shared modules + network module
|
// Initialize DI (Koin) with shared modules + network module
|
||||||
try {
|
try {
|
||||||
initKoin { modules(networkModule, authFeatureModule) }
|
initKoin { modules(networkModule, authFeatureModule, navigationModule) }
|
||||||
println("[DesktopApp] Koin initialized with networkModule + authFeatureModule")
|
println("[DesktopApp] Koin initialized with networkModule + authFeatureModule + navigationModule")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("[DesktopApp] Koin initialization warning: ${e.message}")
|
println("[DesktopApp] Koin initialization warning: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import org.w3c.dom.HTMLElement
|
|||||||
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.clients.authfeature.di.authFeatureModule
|
||||||
|
import navigation.navigationModule
|
||||||
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
fun main() {
|
fun main() {
|
||||||
// Initialize DI
|
// Initialize DI
|
||||||
try {
|
try {
|
||||||
initKoin { modules(networkModule, authFeatureModule) }
|
initKoin { modules(networkModule, authFeatureModule, navigationModule) }
|
||||||
println("[WasmApp] Koin initialized")
|
println("[WasmApp] Koin initialized (with navigationModule)")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("[WasmApp] Koin init failed: ${e.message}")
|
println("[WasmApp] Koin init failed: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user