feat: add drawer logout confirmation and session clear flow
This commit is contained in:
@@ -298,6 +298,64 @@ class BoardsFlowTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun logoutConfirmationClearsSessionAndReturnsToLogin() {
|
||||||
|
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1")
|
||||||
|
val apiKeyStore = InMemoryApiKeyStore("api")
|
||||||
|
MainActivity.dependencies.sessionStoreFactory = { sessionStore }
|
||||||
|
MainActivity.dependencies.apiKeyStoreFactory = { apiKeyStore }
|
||||||
|
MainActivity.dependencies.apiClientFactory = {
|
||||||
|
FakeBoardsApiClient(
|
||||||
|
boards = mutableListOf(BoardSummary("1", "Alpha")),
|
||||||
|
templates = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
|
||||||
|
|
||||||
|
onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open())
|
||||||
|
onView(withId(R.id.drawerLogoutButton)).perform(click())
|
||||||
|
onView(withText(R.string.drawer_logout)).inRoot(isDialog()).perform(click())
|
||||||
|
|
||||||
|
Intents.intended(hasComponent(MainActivity::class.java.name))
|
||||||
|
assertEquals(null, sessionStore.getBaseUrl())
|
||||||
|
assertEquals(null, sessionStore.getWorkspaceId())
|
||||||
|
assertEquals(null, sessionStore.getDraftBaseUrl())
|
||||||
|
assertEquals(null, sessionStore.getDraftApiKey())
|
||||||
|
assertEquals(listOf("https://kan.bn/"), apiKeyStore.invalidatedBaseUrls)
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
assertTrue(activity.isFinishing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun logoutConfirmationCancelKeepsUserOnBoards() {
|
||||||
|
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1")
|
||||||
|
val apiKeyStore = InMemoryApiKeyStore("api")
|
||||||
|
MainActivity.dependencies.sessionStoreFactory = { sessionStore }
|
||||||
|
MainActivity.dependencies.apiKeyStoreFactory = { apiKeyStore }
|
||||||
|
MainActivity.dependencies.apiClientFactory = {
|
||||||
|
FakeBoardsApiClient(
|
||||||
|
boards = mutableListOf(BoardSummary("1", "Alpha")),
|
||||||
|
templates = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
|
||||||
|
|
||||||
|
onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open())
|
||||||
|
onView(withId(R.id.drawerLogoutButton)).perform(click())
|
||||||
|
onView(withText(R.string.cancel)).inRoot(isDialog()).perform(click())
|
||||||
|
|
||||||
|
onView(withText("Alpha")).check(matches(isDisplayed()))
|
||||||
|
assertEquals("https://kan.bn/", sessionStore.getBaseUrl())
|
||||||
|
assertEquals("ws-1", sessionStore.getWorkspaceId())
|
||||||
|
assertTrue(apiKeyStore.invalidatedBaseUrls.isEmpty())
|
||||||
|
scenario.onActivity { activity ->
|
||||||
|
assertFalse(activity.isFinishing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun settingsDialogOpensFromDrawerAndShowsPreferences() {
|
fun settingsDialogOpensFromDrawerAndShowsPreferences() {
|
||||||
MainActivity.dependencies.apiClientFactory = {
|
MainActivity.dependencies.apiClientFactory = {
|
||||||
@@ -536,6 +594,9 @@ class BoardsFlowTest {
|
|||||||
private var baseUrl: String? = null,
|
private var baseUrl: String? = null,
|
||||||
private var workspaceId: String? = "ws-1",
|
private var workspaceId: String? = "ws-1",
|
||||||
) : SessionStore {
|
) : SessionStore {
|
||||||
|
private var draftBaseUrl: String? = baseUrl
|
||||||
|
private var draftApiKey: String? = null
|
||||||
|
|
||||||
override fun getBaseUrl(): String? = baseUrl
|
override fun getBaseUrl(): String? = baseUrl
|
||||||
|
|
||||||
override fun saveBaseUrl(url: String) {
|
override fun saveBaseUrl(url: String) {
|
||||||
@@ -555,11 +616,29 @@ class BoardsFlowTest {
|
|||||||
override fun clearWorkspaceId() {
|
override fun clearWorkspaceId() {
|
||||||
workspaceId = null
|
workspaceId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun initializeDraftsFromCommitted() {
|
||||||
|
draftBaseUrl = baseUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDraftBaseUrl(): String? = draftBaseUrl
|
||||||
|
|
||||||
|
override fun saveDraftBaseUrl(url: String) {
|
||||||
|
draftBaseUrl = url
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDraftApiKey(): String? = draftApiKey
|
||||||
|
|
||||||
|
override fun saveDraftApiKey(apiKey: String) {
|
||||||
|
draftApiKey = apiKey
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class InMemoryApiKeyStore(
|
private class InMemoryApiKeyStore(
|
||||||
private var key: String?,
|
private var key: String?,
|
||||||
) : ApiKeyStore {
|
) : ApiKeyStore {
|
||||||
|
val invalidatedBaseUrls = mutableListOf<String>()
|
||||||
|
|
||||||
override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> {
|
override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> {
|
||||||
key = apiKey
|
key = apiKey
|
||||||
return Result.success(Unit)
|
return Result.success(Unit)
|
||||||
@@ -568,6 +647,7 @@ class BoardsFlowTest {
|
|||||||
override suspend fun getApiKey(baseUrl: String): Result<String?> = Result.success(key)
|
override suspend fun getApiKey(baseUrl: String): Result<String?> = Result.success(key)
|
||||||
|
|
||||||
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
|
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
|
||||||
|
invalidatedBaseUrls += baseUrl
|
||||||
key = null
|
key = null
|
||||||
return Result.success(Unit)
|
return Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ class BoardsActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
drawerLogoutButton.setOnClickListener {
|
drawerLogoutButton.setOnClickListener {
|
||||||
forceSignOutToLogin()
|
showLogoutConfirmation()
|
||||||
}
|
}
|
||||||
|
|
||||||
drawerLayout.addDrawerListener(
|
drawerLayout.addDrawerListener(
|
||||||
@@ -300,7 +300,12 @@ class BoardsActivity : AppCompatActivity() {
|
|||||||
apiKeyStore.invalidateApiKey(baseUrl)
|
apiKeyStore.invalidateApiKey(baseUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
sessionStore.clearBaseUrl()
|
||||||
|
sessionStore.clearApiKey()
|
||||||
sessionStore.clearWorkspaceId()
|
sessionStore.clearWorkspaceId()
|
||||||
|
sessionStore.saveDraftBaseUrl("")
|
||||||
|
sessionStore.saveDraftApiKey("")
|
||||||
|
viewModel.clearSessionUiState()
|
||||||
val intent = Intent(this@BoardsActivity, MainActivity::class.java)
|
val intent = Intent(this@BoardsActivity, MainActivity::class.java)
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
@@ -308,6 +313,16 @@ class BoardsActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showLogoutConfirmation() {
|
||||||
|
MaterialAlertDialogBuilder(this)
|
||||||
|
.setMessage(getString(R.string.drawer_logout_confirmation_message))
|
||||||
|
.setNegativeButton(R.string.cancel, null)
|
||||||
|
.setPositiveButton(R.string.drawer_logout) { _, _ ->
|
||||||
|
forceSignOutToLogin()
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
private fun showCreateBoardDialog() {
|
private fun showCreateBoardDialog() {
|
||||||
val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_create_board, null)
|
val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_create_board, null)
|
||||||
val nameLayout: TextInputLayout = dialogView.findViewById(R.id.createBoardNameLayout)
|
val nameLayout: TextInputLayout = dialogView.findViewById(R.id.createBoardNameLayout)
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ class BoardsRepository(
|
|||||||
private val apiClient: KanbnApiClient,
|
private val apiClient: KanbnApiClient,
|
||||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
) {
|
) {
|
||||||
|
fun clearSessionCaches() {
|
||||||
|
// No-op: this repository currently keeps no in-memory session cache.
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun loadDrawerData(): DrawerDataResult {
|
suspend fun loadDrawerData(): DrawerDataResult {
|
||||||
val session = when (val sessionResult = authSession()) {
|
val session = when (val sessionResult = authSession()) {
|
||||||
is BoardsApiResult.Success -> sessionResult.value
|
is BoardsApiResult.Success -> sessionResult.value
|
||||||
|
|||||||
@@ -129,6 +129,13 @@ class BoardsViewModel(
|
|||||||
fetchBoards(initial = false, refresh = true)
|
fetchBoards(initial = false, refresh = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun clearSessionUiState() {
|
||||||
|
repository.clearSessionCaches()
|
||||||
|
lastDrawerLoadAtMillis = null
|
||||||
|
hadDrawerLoadFailureSinceLastSuccess = false
|
||||||
|
_uiState.value = BoardsUiState()
|
||||||
|
}
|
||||||
|
|
||||||
fun onSettingsApplied(credentialsChanged: Boolean) {
|
fun onSettingsApplied(credentialsChanged: Boolean) {
|
||||||
if (!credentialsChanged) {
|
if (!credentialsChanged) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -87,6 +87,7 @@
|
|||||||
<string name="drawer_settings">Settings</string>
|
<string name="drawer_settings">Settings</string>
|
||||||
<string name="drawer_workspaces">Workspaces</string>
|
<string name="drawer_workspaces">Workspaces</string>
|
||||||
<string name="drawer_logout">Log out</string>
|
<string name="drawer_logout">Log out</string>
|
||||||
|
<string name="drawer_logout_confirmation_message">Log out and clear this device session?</string>
|
||||||
<string name="drawer_retry">Retry</string>
|
<string name="drawer_retry">Retry</string>
|
||||||
<string name="drawer_profile_unavailable">Profile unavailable</string>
|
<string name="drawer_profile_unavailable">Profile unavailable</string>
|
||||||
<string name="drawer_workspaces_unavailable">Workspaces unavailable</string>
|
<string name="drawer_workspaces_unavailable">Workspaces unavailable</string>
|
||||||
|
|||||||
@@ -478,6 +478,33 @@ class BoardsViewModelTest {
|
|||||||
assertTrue(api.listBoardsCalls >= 1)
|
assertTrue(api.listBoardsCalls >= 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clearSessionUiStateResetsBoardsStateToDefaults() = runTest {
|
||||||
|
val api = FakeBoardsApiClient().apply {
|
||||||
|
listBoardsResult = BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha")))
|
||||||
|
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = "alice@example.com"))
|
||||||
|
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
|
||||||
|
}
|
||||||
|
val viewModel = newViewModel(api)
|
||||||
|
|
||||||
|
viewModel.loadBoards()
|
||||||
|
viewModel.loadDrawerData()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(1, viewModel.uiState.value.boards.size)
|
||||||
|
assertEquals("Alice", viewModel.uiState.value.drawer.profile?.displayName)
|
||||||
|
|
||||||
|
viewModel.clearSessionUiState()
|
||||||
|
|
||||||
|
val state = viewModel.uiState.value
|
||||||
|
assertTrue(state.isInitialLoading)
|
||||||
|
assertFalse(state.isRefreshing)
|
||||||
|
assertFalse(state.isMutating)
|
||||||
|
assertTrue(state.boards.isEmpty())
|
||||||
|
assertTrue(state.templates.isEmpty())
|
||||||
|
assertFalse(state.isTemplatesLoading)
|
||||||
|
assertEquals(BoardsDrawerState(), state.drawer)
|
||||||
|
}
|
||||||
|
|
||||||
private fun newViewModel(
|
private fun newViewModel(
|
||||||
apiClient: FakeBoardsApiClient,
|
apiClient: FakeBoardsApiClient,
|
||||||
sessionStore: InMemorySessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1"),
|
sessionStore: InMemorySessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1"),
|
||||||
|
|||||||
Reference in New Issue
Block a user