Skip to content

Commit ece0f76

Browse files
committed
feat(auth): Implement session expiration handling and improved login flow
This commit introduces a mechanism to detect and handle expired GitHub sessions and enhances the device authentication UI with countdown timers and better error categorization. - **feat(auth)**: Added `SessionExpiredDialog` to notify users when their session is invalid or revoked. - **feat(auth)**: Integrated `UnauthorizedInterceptor` (401 handler) into the Ktor `HttpClient` to automatically trigger session expiration events. - **feat(auth)**: Added a countdown timer to the device login screen to show remaining time for code verification. - **feat(auth)**: Introduced `SkipLogin` (Continue as Guest) option to the authentication flow. - **feat(auth)**: Improved error handling in `AuthenticationViewModel` with specific recovery hints (e.g., connection issues, denied access). - **refactor(core)**: Updated `AuthenticationState` and `TokenStore` to manage session expiration events and token lifecycle (including `saved_at` timestamp). - **refactor(cache)**: Added `clearAll()` to `CacheManager` to ensure all local data is purged upon logout or session expiration. - **i18n**: Added string resources for session expiration, auth hints, and logout notes. - **chore**: Updated `LogoutDialog` to include a note about revoking access via GitHub settings.
1 parent d48f6ee commit ece0f76

22 files changed

Lines changed: 361 additions & 9 deletions

File tree

composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import zed.rainxch.githubstore.app.deeplink.DeepLinkParser
1616
import zed.rainxch.githubstore.app.navigation.AppNavigation
1717
import zed.rainxch.githubstore.app.navigation.GithubStoreGraph
1818
import zed.rainxch.githubstore.app.components.RateLimitDialog
19+
import zed.rainxch.githubstore.app.components.SessionExpiredDialog
1920

2021
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
2122
@Composable
@@ -70,6 +71,18 @@ fun App(deepLinkUri: String? = null) {
7071
}
7172
}
7273

74+
if (state.showSessionExpiredDialog) {
75+
SessionExpiredDialog(
76+
onDismiss = {
77+
viewModel.onAction(MainAction.DismissSessionExpiredDialog)
78+
},
79+
onSignIn = {
80+
viewModel.onAction(MainAction.DismissSessionExpiredDialog)
81+
navBackStack.navigate(GithubStoreGraph.AuthenticationScreen)
82+
}
83+
)
84+
}
85+
7386
AppNavigation(
7487
navController = navBackStack
7588
)

composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainAction.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ package zed.rainxch.githubstore
22

33
sealed interface MainAction {
44
data object DismissRateLimitDialog : MainAction
5+
data object DismissSessionExpiredDialog : MainAction
56
}

composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainState.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ data class MainState(
88
val isLoggedIn: Boolean = false,
99
val rateLimitInfo: RateLimitInfo? = null,
1010
val showRateLimitDialog: Boolean = false,
11+
val showSessionExpiredDialog: Boolean = false,
1112
val currentColorTheme: AppTheme = AppTheme.OCEAN,
1213
val isAmoledTheme: Boolean = false,
1314
val isDarkTheme: Boolean? = null,

composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/MainViewModel.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ class MainViewModel(
8989
}
9090
}
9191

92+
viewModelScope.launch {
93+
authenticationState.sessionExpiredEvent.collect {
94+
_state.update { it.copy(showSessionExpiredDialog = true) }
95+
}
96+
}
97+
9298
viewModelScope.launch(Dispatchers.IO) {
9399
syncUseCase().onSuccess {
94100
installedAppsRepository.checkAllForUpdates()
@@ -101,6 +107,9 @@ class MainViewModel(
101107
MainAction.DismissRateLimitDialog -> {
102108
_state.update { it.copy(showRateLimitDialog = false) }
103109
}
110+
MainAction.DismissSessionExpiredDialog -> {
111+
_state.update { it.copy(showSessionExpiredDialog = false) }
112+
}
104113
}
105114
}
106115
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package zed.rainxch.githubstore.app.components
2+
3+
import androidx.compose.foundation.layout.Arrangement
4+
import androidx.compose.foundation.layout.Column
5+
import androidx.compose.material.icons.Icons
6+
import androidx.compose.material.icons.filled.LockOpen
7+
import androidx.compose.material3.AlertDialog
8+
import androidx.compose.material3.Button
9+
import androidx.compose.material3.Icon
10+
import androidx.compose.material3.MaterialTheme
11+
import androidx.compose.material3.Text
12+
import androidx.compose.material3.TextButton
13+
import androidx.compose.runtime.Composable
14+
import androidx.compose.ui.text.font.FontWeight
15+
import androidx.compose.ui.unit.dp
16+
import org.jetbrains.compose.resources.stringResource
17+
import zed.rainxch.githubstore.core.presentation.res.*
18+
19+
@Composable
20+
fun SessionExpiredDialog(
21+
onDismiss: () -> Unit,
22+
onSignIn: () -> Unit
23+
) {
24+
AlertDialog(
25+
onDismissRequest = onDismiss,
26+
icon = {
27+
Icon(
28+
imageVector = Icons.Default.LockOpen,
29+
contentDescription = null,
30+
tint = MaterialTheme.colorScheme.error
31+
)
32+
},
33+
title = {
34+
Text(
35+
text = stringResource(Res.string.session_expired_title),
36+
style = MaterialTheme.typography.headlineSmall,
37+
fontWeight = FontWeight.Black,
38+
color = MaterialTheme.colorScheme.onSurface
39+
)
40+
},
41+
text = {
42+
Column(
43+
verticalArrangement = Arrangement.spacedBy(8.dp)
44+
) {
45+
Text(
46+
text = stringResource(Res.string.session_expired_message),
47+
style = MaterialTheme.typography.bodyMedium,
48+
color = MaterialTheme.colorScheme.outline
49+
)
50+
51+
Text(
52+
text = stringResource(Res.string.session_expired_hint),
53+
style = MaterialTheme.typography.bodySmall,
54+
color = MaterialTheme.colorScheme.primary
55+
)
56+
}
57+
},
58+
confirmButton = {
59+
Button(onClick = onSignIn) {
60+
Text(
61+
text = stringResource(Res.string.sign_in_again),
62+
style = MaterialTheme.typography.bodySmall,
63+
color = MaterialTheme.colorScheme.onPrimary
64+
)
65+
}
66+
},
67+
dismissButton = {
68+
TextButton(onClick = onDismiss) {
69+
Text(
70+
text = stringResource(Res.string.continue_as_guest),
71+
style = MaterialTheme.typography.bodySmall,
72+
color = MaterialTheme.colorScheme.onSurface
73+
)
74+
}
75+
}
76+
)
77+
}

core/data/src/commonMain/kotlin/zed/rainxch/core/data/cache/CacheManager.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ class CacheManager(
8585
cacheDao.deleteByPrefix(prefix)
8686
}
8787

88+
suspend fun clearAll() {
89+
memoryCache.clear()
90+
cacheDao.deleteAll()
91+
}
92+
8893
suspend fun cleanupExpired() {
8994
val currentTime = now()
9095
val expiredKeys = memoryCache.entries

core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/TokenStore.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ interface TokenStore {
99
fun blockingCurrentToken() : GithubDeviceTokenSuccessDto?
1010
suspend fun save(token: GithubDeviceTokenSuccessDto)
1111
suspend fun clear()
12+
suspend fun isTokenExpired(): Boolean
1213
}

core/data/src/commonMain/kotlin/zed/rainxch/core/data/data_source/impl/DefaultTokenStore.kt

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ class DefaultTokenStore(
1919
private val json = Json { ignoreUnknownKeys = true }
2020

2121
override suspend fun save(token: GithubDeviceTokenSuccessDto) {
22-
val jsonString = json.encodeToString(GithubDeviceTokenSuccessDto.serializer(), token)
22+
val stamped = token.copy(
23+
savedAtEpochMillis = token.savedAtEpochMillis ?: System.currentTimeMillis()
24+
)
25+
val jsonString = json.encodeToString(GithubDeviceTokenSuccessDto.serializer(), stamped)
2326
dataStore.edit { preferences ->
2427
preferences[TOKEN_KEY] = jsonString
2528
}
@@ -50,8 +53,15 @@ class DefaultTokenStore(
5053
}.getOrNull()
5154
}
5255

53-
5456
override suspend fun clear() {
5557
dataStore.edit { it.remove(TOKEN_KEY) }
5658
}
59+
60+
override suspend fun isTokenExpired(): Boolean {
61+
val token = currentToken() ?: return true
62+
val savedAt = token.savedAtEpochMillis ?: return false
63+
val expiresIn = token.expiresIn ?: return false
64+
val expiresAtMillis = savedAt + (expiresIn * 1000L)
65+
return System.currentTimeMillis() > expiresAtMillis
66+
}
5767
}

core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,14 +143,17 @@ val networkModule = module {
143143
GitHubClientProvider(
144144
tokenStore = get(),
145145
rateLimitRepository = get(),
146+
authenticationState = get(),
146147
proxyConfigFlow = ProxyManager.currentProxyConfig
147148
)
148149
}
149150

150151
single<HttpClient> {
151152
createGitHubHttpClient(
152153
tokenStore = get(),
153-
rateLimitRepository = get()
154+
rateLimitRepository = get(),
155+
authenticationState = get(),
156+
scope = get()
154157
)
155158
}
156159

core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/GithubDeviceTokenSuccessDto.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@ data class GithubDeviceTokenSuccessDto(
1010
@SerialName("expires_in") val expiresIn: Long? = null,
1111
@SerialName("scope") val scope: String? = null,
1212
@SerialName("refresh_token") val refreshToken: String? = null,
13-
@SerialName("refresh_token_expires_in") val refreshTokenExpiresIn: Long? = null
13+
@SerialName("refresh_token_expires_in") val refreshTokenExpiresIn: Long? = null,
14+
@SerialName("saved_at") val savedAtEpochMillis: Long? = null
1415
)

0 commit comments

Comments
 (0)