Repository: openMF/mobile-wallet
Pull request #1864: Logout on Unauthorized access
Author: login: Shreyash16b
Created: 2025-05-11T16:37:27Z | Merged: 2025-05-20T15:04:34Z

## Issue Fix
Jira Task: https://mifosforge.jira.com/browse/MW-206


## Description
1. Intercept to view the HttpResponse for HttpStatusCode.Unauthorized
2. Create a global MutableStateFlow isUnauthorized
3. Logout of the application 

## Video

https://github.com/user-attachments/assets/5a44759d-73fa-4af7-a35d-e9f508ca448b

When an invalid token is present in the header the app asks user to login again.

##
<!--Please make sure these boxes are checked before submitting your pull request - thanks!-->

- [ ] Apply the `AndroidStyle.xml` style template to your code in Android Studio.

- [✅] Run the unit tests with `./gradlew check` to make sure you didn't break anything

- [✅] If you have multiple commits please combine them into one commit by squashing them.




Diff:
diff --git a/core/common/src/commonMain/kotlin/org/mifospay/core/common/GlobalAuthManager.kt b/core/common/src/commonMain/kotlin/org/mifospay/core/common/GlobalAuthManager.kt
new file mode 100644
index 000000000..fb1041748
--- /dev/null
+++ b/core/common/src/commonMain/kotlin/org/mifospay/core/common/GlobalAuthManager.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md
+ */
+package org.mifospay.core.common
+
+import kotlinx.coroutines.flow.MutableStateFlow
+
+object GlobalAuthManager {
+    val isUnauthorized = MutableStateFlow(false)
+
+    fun markUnauthorized() {
+        isUnauthorized.value = true
+    }
+
+    fun reset() {
+        isUnauthorized.value = false
+    }
+}
diff --git a/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/component/AlertDialog.kt b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/component/AlertDialog.kt
index 8a0115a0f..76437c97a 100644
--- a/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/component/AlertDialog.kt
+++ b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/component/AlertDialog.kt
@@ -22,7 +22,7 @@ fun MifosDialogBox(
     title: String,
     showDialogState: Boolean,
     confirmButtonText: String,
-    dismissButtonText: String,
+    dismissButtonText: String? = null,
     onConfirm: () -> Unit,
     onDismiss: () -> Unit,
     modifier: Modifier = Modifier,
@@ -48,8 +48,10 @@ fun MifosDialogBox(
                 }
             },
             dismissButton = {
-                TextButton(onClick = onDismiss) {
-                    Text(text = dismissButtonText)
+                if (!dismissButtonText.isNullOrBlank()) {
+                    TextButton(onClick = onDismiss) {
+                        Text(text = dismissButtonText)
+                    }
                 }
             },
         )
diff --git a/core/network/src/commonMain/kotlin/org/mifospay/core/network/utils/KtorInterceptor.kt b/core/network/src/commonMain/kotlin/org/mifospay/core/network/utils/KtorInterceptor.kt
index 060f025a6..80125e7ba 100644
--- a/core/network/src/commonMain/kotlin/org/mifospay/core/network/utils/KtorInterceptor.kt
+++ b/core/network/src/commonMain/kotlin/org/mifospay/core/network/utils/KtorInterceptor.kt
@@ -13,7 +13,10 @@ import io.ktor.client.HttpClient
 import io.ktor.client.plugins.HttpClientPlugin
 import io.ktor.client.request.HttpRequestPipeline
 import io.ktor.client.request.header
+import io.ktor.client.statement.HttpResponsePipeline
+import io.ktor.http.HttpStatusCode
 import io.ktor.util.AttributeKey
+import org.mifospay.core.common.GlobalAuthManager
 import org.mifospay.core.datastore.UserPreferencesRepository
 
 class KtorInterceptor(
@@ -38,6 +41,13 @@ class KtorInterceptor(
                     }
                 }
             }
+
+            scope.responsePipeline.intercept(HttpResponsePipeline.After) {
+                if (context.response.status == HttpStatusCode.Unauthorized) {
+                    GlobalAuthManager.markUnauthorized()
+                }
+                proceedWith(subject)
+            }
         }
 
         override fun prepare(block: Config.() -> Unit): KtorInterceptor {
@@ -75,6 +85,13 @@ class KtorInterceptorRe(
                     }
                 }
             }
+
+            scope.responsePipeline.intercept(HttpResponsePipeline.After) {
+                if (context.response.status == HttpStatusCode.Unauthorized) {
+                    GlobalAuthManager.markUnauthorized()
+                }
+                proceedWith(subject)
+            }
         }
 
         override fun prepare(block: ConfigRe.() -> Unit): KtorInterceptorRe {
diff --git a/mifospay-shared/src/commonMain/kotlin/org/mifospay/shared/MifosPayApp.kt b/mifospay-shared/src/commonMain/kotlin/org/mifospay/shared/MifosPayApp.kt
index e58580e0d..39ca953e3 100644
--- a/mifospay-shared/src/commonMain/kotlin/org/mifospay/shared/MifosPayApp.kt
+++ b/mifospay-shared/src/commonMain/kotlin/org/mifospay/shared/MifosPayApp.kt
@@ -10,14 +10,19 @@
 package org.mifospay.shared
 
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
 import androidx.compose.ui.Modifier
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.navigation.compose.rememberNavController
 import org.koin.compose.koinInject
 import org.koin.compose.viewmodel.koinViewModel
+import org.mifospay.core.common.GlobalAuthManager
 import org.mifospay.core.data.util.NetworkMonitor
 import org.mifospay.core.data.util.TimeZoneMonitor
+import org.mifospay.core.designsystem.component.MifosDialogBox
 import org.mifospay.core.designsystem.theme.MifosTheme
 import org.mifospay.shared.MainUiState.Success
 import org.mifospay.shared.navigation.MifosNavGraph.LOGIN_GRAPH
@@ -43,6 +48,35 @@ private fun MifosPayApp(
     val uiState by viewModel.uiState.collectAsStateWithLifecycle()
     val navController = rememberNavController()
 
+    var showErrorDialog = remember { mutableStateOf<Boolean>(false) }
+    val isUnauthorized by GlobalAuthManager.isUnauthorized.collectAsStateWithLifecycle()
+
+    LaunchedEffect(isUnauthorized) {
+        if (isUnauthorized) {
+            showErrorDialog.value = true
+        }
+    }
+
+    if (showErrorDialog.value) {
+        MifosDialogBox(
+            title = "Unauthorized User",
+            showDialogState = showErrorDialog.value,
+            confirmButtonText = "Ok",
+            onConfirm = {
+                showErrorDialog.value = false
+                viewModel.logOut()
+                navController.navigate(LOGIN_GRAPH) {
+                    popUpTo(navController.graph.id) {
+                        inclusive = true
+                    }
+                }
+                GlobalAuthManager.reset()
+            },
+            onDismiss = {},
+            message = "Please login again to continue",
+        )
+    }
+
     val navDestination = when (uiState) {
         is MainUiState.Loading -> LOGIN_GRAPH
         is Success -> if ((uiState as Success).userData.authenticated) {


Reviews:
user: login: niyajali
body: Hi! Thank you for your efforts, here's few things we need to address, and let us know or mention me after you fix these we'll proceed with merge.
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-05-11T18:15:37Z
user: login: niyajali
body: Looks good to me can be merged, can you upload a video to demonstrate this functionality and work as expected. After that we'll proceed with merge.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-15T15:47:45Z
user: login: HekmatullahAmin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-17T12:15:04Z
user: login: Shreyash16b
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-17T13:01:54Z
user: login: HekmatullahAmin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-17T13:51:59Z
user: login: Shreyash16b
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-18T12:06:53Z
user: login: HekmatullahAmin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-19T15:08:39Z
user: login: Shreyash16b
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T15:48:22Z
user: login: niyajali
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-20T15:04:18Z

Review comments:
diff_hunk: @@ -0,0 +1,45 @@
+/*
+ * Copyright 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md
+ */
+package org.mifospay.feature.auth
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import org.mifospay.core.common.GlobalAuthManager
+import org.mifospay.core.data.util.Constants.UNAUTHORIZED_ERROR
+import org.mifospay.core.designsystem.component.BasicDialogState
+import org.mifospay.core.designsystem.component.MifosBasicDialog
+
+@Composable
+fun AuthErrorDialog(
+    onLogout: () -> Unit,
+) {
+    val dialogState = remember { mutableStateOf<BasicDialogState>(BasicDialogState.Hidden) }
+
+    LaunchedEffect(Unit) {
path: feature/auth/src/commonMain/kotlin/org/mifospay/feature/auth/AuthErrorDialog.kt
user: login: niyajali
body: Create a variable like `val isAuthorized=GlobalAuthManager.isUnauthorized.viewModel.uiState.collectAsStateWithLifecycle()` and pass this as key to Effect handler(LunchedEffect) to prevent unnecessary call and it will call when the value of this parameter actually changed.
created_at: 2025-05-11T18:01:37Z
author_association: COLLABORATOR
diff_hunk: @@ -75,6 +78,14 @@ class KtorInterceptorRe(
                     }
                 }
             }
+
+            scope.receivePipeline.intercept(HttpResponsePipeline.After) { response ->
+                if (response.status == HttpStatusCode.Unauthorized) {
+                    plugin.repository.logOut()
path: core/network/src/commonMain/kotlin/org/mifospay/core/network/utils/KtorInterceptor.kt
user: login: niyajali
body: We no need to call logOut method here since we're calling it from the MifosPayApp 
created_at: 2025-05-11T18:05:13Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,45 @@
+/*
+ * Copyright 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md
+ */
+package org.mifospay.feature.auth
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import org.mifospay.core.common.GlobalAuthManager
+import org.mifospay.core.data.util.Constants.UNAUTHORIZED_ERROR
+import org.mifospay.core.designsystem.component.BasicDialogState
+import org.mifospay.core.designsystem.component.MifosBasicDialog
+
+@Composable
+fun AuthErrorDialog(
+    onLogout: () -> Unit,
+) {
+    val dialogState = remember { mutableStateOf<BasicDialogState>(BasicDialogState.Hidden) }
path: feature/auth/src/commonMain/kotlin/org/mifospay/feature/auth/AuthErrorDialog.kt
user: login: niyajali
body: This state could be moved into top level composable like in the MifosPayApp composable.
created_at: 2025-05-11T18:06:08Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,45 @@
+/*
+ * Copyright 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md
+ */
+package org.mifospay.feature.auth
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import org.mifospay.core.common.GlobalAuthManager
+import org.mifospay.core.data.util.Constants.UNAUTHORIZED_ERROR
+import org.mifospay.core.designsystem.component.BasicDialogState
+import org.mifospay.core.designsystem.component.MifosBasicDialog
+
+@Composable
+fun AuthErrorDialog(
+    onLogout: () -> Unit,
+) {
+    val dialogState = remember { mutableStateOf<BasicDialogState>(BasicDialogState.Hidden) }
+
+    LaunchedEffect(Unit) {
path: feature/auth/src/commonMain/kotlin/org/mifospay/feature/auth/AuthErrorDialog.kt
user: login: niyajali
body: Also this effect handler could be moved into top level composable.
created_at: 2025-05-11T18:07:09Z
author_association: COLLABORATOR
diff_hunk: @@ -43,6 +50,32 @@ private fun MifosPayApp(
     val uiState by viewModel.uiState.collectAsStateWithLifecycle()
     val navController = rememberNavController()
 
+    val dialogState = remember { mutableStateOf<BasicDialogState>(BasicDialogState.Hidden) }
+    val isUnauthorized by GlobalAuthManager.isUnauthorized.collectAsStateWithLifecycle()
+
+    LaunchedEffect(isUnauthorized) {
+        if (isUnauthorized) {
+            dialogState.value = BasicDialogState.Shown(
+                title = UNAUTHORIZED_ERROR,
+                message = "Your session has expired. Please log in again.",
+            )
+        }
+    }
+
+    AuthErrorDialog(
+        dialogState = dialogState.value,
+        onDismiss = {
path: mifospay-shared/src/commonMain/kotlin/org/mifospay/shared/MifosPayApp.kt
user: login: HekmatullahAmin
body: - @Shreyash16b As @niyajali mentioned paste a video so we can we see that it is working.
- and second in this dialog are you trying to logout both when user clicks outside the Alert dialog and also in confirm button? if this is the case and asked you this way that is fine although it is not a good user experience cause when dialog is dismissed it means they don't want to take any action for example the click outside of the dialog. 
- But if it is not the case and you want the user have option to dimiss it then you can use another `MifosBasicDialog` with `onConfirm` parameter.
created_at: 2025-05-17T12:14:01Z
author_association: MEMBER
diff_hunk: @@ -43,6 +50,32 @@ private fun MifosPayApp(
     val uiState by viewModel.uiState.collectAsStateWithLifecycle()
     val navController = rememberNavController()
 
+    val dialogState = remember { mutableStateOf<BasicDialogState>(BasicDialogState.Hidden) }
+    val isUnauthorized by GlobalAuthManager.isUnauthorized.collectAsStateWithLifecycle()
+
+    LaunchedEffect(isUnauthorized) {
+        if (isUnauthorized) {
+            dialogState.value = BasicDialogState.Shown(
+                title = UNAUTHORIZED_ERROR,
+                message = "Your session has expired. Please log in again.",
+            )
+        }
+    }
+
+    AuthErrorDialog(
+        dialogState = dialogState.value,
+        onDismiss = {
path: mifospay-shared/src/commonMain/kotlin/org/mifospay/shared/MifosPayApp.kt
user: login: Shreyash16b
body: - I have posted a video [here](https://github.com/openMF/mobile-wallet/pull/1864#issue-3055086772)
- Nothing was mentioned about handling user click outside the dialog. I did it this way because I thought that the user should be logged out on unauthorized access
- If it does not follow the app's UX philosophy, I can change that to logout onConfirm and no action onDismiss.
created_at: 2025-05-17T13:01:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -43,6 +50,32 @@ private fun MifosPayApp(
     val uiState by viewModel.uiState.collectAsStateWithLifecycle()
     val navController = rememberNavController()
 
+    val dialogState = remember { mutableStateOf<BasicDialogState>(BasicDialogState.Hidden) }
+    val isUnauthorized by GlobalAuthManager.isUnauthorized.collectAsStateWithLifecycle()
+
+    LaunchedEffect(isUnauthorized) {
+        if (isUnauthorized) {
+            dialogState.value = BasicDialogState.Shown(
+                title = UNAUTHORIZED_ERROR,
+                message = "Your session has expired. Please log in again.",
+            )
+        }
+    }
+
+    AuthErrorDialog(
+        dialogState = dialogState.value,
+        onDismiss = {
path: mifospay-shared/src/commonMain/kotlin/org/mifospay/shared/MifosPayApp.kt
user: login: HekmatullahAmin
body: - great previously when i tried to play it i wasn't able to do it.
- yeah it would be great onDimiss will be disabled and user will be forced to click ok and then will be logout. So the user will know that what happened and I pressed ok and logged out. But logging out even when user clicks outside the box is kind of confusing for user did I get logged out, or my app crashed... 
- and also you can directly call `MifosBasicDialog` no need to create another composable function `AuthErrorDialog` it will just adding extra line of code.
created_at: 2025-05-17T13:51:58Z
author_association: MEMBER
diff_hunk: @@ -43,6 +50,32 @@ private fun MifosPayApp(
     val uiState by viewModel.uiState.collectAsStateWithLifecycle()
     val navController = rememberNavController()
 
+    val dialogState = remember { mutableStateOf<BasicDialogState>(BasicDialogState.Hidden) }
+    val isUnauthorized by GlobalAuthManager.isUnauthorized.collectAsStateWithLifecycle()
+
+    LaunchedEffect(isUnauthorized) {
+        if (isUnauthorized) {
+            dialogState.value = BasicDialogState.Shown(
+                title = UNAUTHORIZED_ERROR,
+                message = "Your session has expired. Please log in again.",
+            )
+        }
+    }
+
+    AuthErrorDialog(
+        dialogState = dialogState.value,
+        onDismiss = {
path: mifospay-shared/src/commonMain/kotlin/org/mifospay/shared/MifosPayApp.kt
user: login: Shreyash16b
body: I have made the required changes.
created_at: 2025-05-18T12:06:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -43,6 +50,32 @@ private fun MifosPayApp(
     val uiState by viewModel.uiState.collectAsStateWithLifecycle()
     val navController = rememberNavController()
 
+    val dialogState = remember { mutableStateOf<BasicDialogState>(BasicDialogState.Hidden) }
+    val isUnauthorized by GlobalAuthManager.isUnauthorized.collectAsStateWithLifecycle()
+
+    LaunchedEffect(isUnauthorized) {
+        if (isUnauthorized) {
+            dialogState.value = BasicDialogState.Shown(
+                title = UNAUTHORIZED_ERROR,
+                message = "Your session has expired. Please log in again.",
+            )
+        }
+    }
+
+    AuthErrorDialog(
+        dialogState = dialogState.value,
+        onDismiss = {
path: mifospay-shared/src/commonMain/kotlin/org/mifospay/shared/MifosPayApp.kt
user: login: HekmatullahAmin
body: @Shreyash16b great is it working as expected?
created_at: 2025-05-19T15:08:39Z
author_association: MEMBER
diff_hunk: @@ -43,6 +50,32 @@ private fun MifosPayApp(
     val uiState by viewModel.uiState.collectAsStateWithLifecycle()
     val navController = rememberNavController()
 
+    val dialogState = remember { mutableStateOf<BasicDialogState>(BasicDialogState.Hidden) }
+    val isUnauthorized by GlobalAuthManager.isUnauthorized.collectAsStateWithLifecycle()
+
+    LaunchedEffect(isUnauthorized) {
+        if (isUnauthorized) {
+            dialogState.value = BasicDialogState.Shown(
+                title = UNAUTHORIZED_ERROR,
+                message = "Your session has expired. Please log in again.",
+            )
+        }
+    }
+
+    AuthErrorDialog(
+        dialogState = dialogState.value,
+        onDismiss = {
path: mifospay-shared/src/commonMain/kotlin/org/mifospay/shared/MifosPayApp.kt
user: login: Shreyash16b
body: @HekmatullahAmin Yes, it works on 401 error. To test this, I manually changed the _context.headers[HEADER_AUTH]_ , and it ended up showing the error dialog box as in the video.
created_at: 2025-05-19T15:48:22Z
author_association: CONTRIBUTOR

PR comments:
user: login: Shreyash16b
created_at: 2025-05-15T15:44:29Z
body: @niyajali I have made the suggested changes
author_association: CONTRIBUTOR
user: login: Shreyash16b
created_at: 2025-05-16T14:41:35Z
body: For the video demonstration, i modified the header token to trigger the interceptor and get a HttpStatus.UNAUTHORIZED.
This seemed like the only practical way I could get it for testing.
author_association: CONTRIBUTOR
user: login: niyajali
created_at: 2025-05-19T15:56:22Z
body: @Shreyash16b After watching the video that you've shared cancel button is unnecessary not good for UI/UX, remove it and only keep "Ok" button.
author_association: COLLABORATOR
user: login: niyajali
created_at: 2025-05-19T15:57:44Z
body: Also update the dialog title and message with proper human understandable text.
author_association: COLLABORATOR
user: login: Shreyash16b
created_at: 2025-05-20T13:39:10Z
body: @niyajali I have made the required changes.
author_association: CONTRIBUTOR
user: login: niyajali
created_at: 2025-05-20T15:09:18Z
body: @Shreyash16b Hi, Thanks for the PR, Did you joined our slack community if not join now(link in readme) and join our channel mifos-pay channel and if you would like to work on other tickets then let's us know on channel we'll assign some tickets.
author_association: COLLABORATOR
user: login: Shreyash16b
created_at: 2025-05-20T15:30:46Z
body: @niyajali Thank you, I have joined the slack channel and am assigned another ticket.
author_association: CONTRIBUTOR
user: login: niyajali
created_at: 2025-05-20T15:33:51Z
body: Hi, that's great, but I suggest joining our daily standup and our project maintainer(Rajan Sir) will assign you some tickets which we're currently focusing on.
author_association: COLLABORATOR
user: login: Shreyash16b
created_at: 2025-05-20T15:46:49Z
body: Yes, I am attending the standup regularly and Rajan Sir has assigned tickets to me.
author_association: CONTRIBUTOR