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:
StefanMo
2025-12-08 14:23:08 +01:00
committed by GitHub
parent df2562ea23
commit 5ea4730cd4
14 changed files with 283 additions and 236 deletions
+2 -1
View File
@@ -29,7 +29,8 @@ kotlin {
sourceSets {
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 {
implementation(libs.kotlin.test)
@@ -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?
}
@@ -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"
}
@@ -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,
}
@@ -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)
}
@@ -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"
}
}
@@ -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")
}
}