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:
@@ -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)
|
||||
|
||||
+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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user