diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index 1f34c696..ddb5bc1b 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -47,12 +47,24 @@
-
-
-
-
+
navController.navigate(route) {
launchSingleTop = true
diff --git a/androidApp/src/main/kotlin/com/fivucsas/mobile/android/ui/screen/DashboardScreen.kt b/androidApp/src/main/kotlin/com/fivucsas/mobile/android/ui/screen/DashboardScreen.kt
index 3169c16f..4a31fff7 100644
--- a/androidApp/src/main/kotlin/com/fivucsas/mobile/android/ui/screen/DashboardScreen.kt
+++ b/androidApp/src/main/kotlin/com/fivucsas/mobile/android/ui/screen/DashboardScreen.kt
@@ -19,7 +19,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Contactless
-import androidx.compose.material.icons.filled.CreditCard
+// import androidx.compose.material.icons.filled.CreditCard // #8 fix: "Add card" QuickAction hidden (see below)
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Notifications
@@ -88,6 +88,7 @@ fun DashboardScreen(
onNavigateToRequestMembership: () -> Unit,
onNavigateToCardScan: () -> Unit,
onNavigateToNfcRead: () -> Unit = {},
+ onNavigateToApproveLogin: () -> Unit = {},
onNavigateBottom: (String) -> Unit,
sessionRepository: SessionRepository = koinInject(),
analyticsViewModel: AnalyticsViewModel = koinInject().disposeOnLeave()
@@ -128,6 +129,13 @@ fun DashboardScreen(
route = Screen.Profile.route,
anyPermissions = setOf(Permission.PROFILE_READ_SELF)
),
+ QuickAction(
+ id = "login-requests",
+ title = s(StringKey.DASH_LOGIN_REQUESTS),
+ icon = Icons.Default.CheckCircle,
+ route = Screen.ApproveLogin.route,
+ anyPermissions = setOf(Permission.PROFILE_READ_SELF)
+ ),
QuickAction(
id = "request-membership",
title = s(StringKey.DASH_JOIN_TENANT),
@@ -135,13 +143,20 @@ fun DashboardScreen(
route = Screen.RequestMembership.route,
anyPermissions = setOf(Permission.TENANT_MEMBERSHIP_REQUEST)
),
- QuickAction(
- id = "card-scan",
- title = s(StringKey.DASH_ADD_CARD),
- icon = Icons.Default.CreditCard,
- route = Screen.CardScan.route,
- anyPermissions = setOf(Permission.CARD_ADD_SELF)
- ),
+ // "Add card" (#8 fix, pre-demo 2026-06-03): HIDDEN. The CardScan flow is a
+ // camera photo wizard that captures front/back images but never uploads or
+ // persists them (CardScanScreen has no API/repository call) — a misleading
+ // dead-end for a demo. The screen + nav route are left intact (CardScanScreen,
+ // Screen.CardScan, onNavigateToCardScan) so this is fully reversible: restore
+ // the QuickAction below once the OCR/upload backend is wired.
+ //
+ // QuickAction(
+ // id = "card-scan",
+ // title = s(StringKey.DASH_ADD_CARD),
+ // icon = Icons.Default.CreditCard,
+ // route = Screen.CardScan.route,
+ // anyPermissions = setOf(Permission.CARD_ADD_SELF)
+ // ),
QuickAction(
id = "nfc-read",
title = s(StringKey.DASH_NFC_READER),
@@ -164,6 +179,7 @@ fun DashboardScreen(
Screen.RequestMembership.route -> onNavigateToRequestMembership()
Screen.CardScan.route -> onNavigateToCardScan()
Screen.NfcRead.route -> onNavigateToNfcRead()
+ Screen.ApproveLogin.route -> onNavigateToApproveLogin()
}
}
)
diff --git a/androidApp/src/main/kotlin/com/fivucsas/mobile/android/ui/screen/NfcReadScreen.kt b/androidApp/src/main/kotlin/com/fivucsas/mobile/android/ui/screen/NfcReadScreen.kt
index 54bf57da..3b324444 100644
--- a/androidApp/src/main/kotlin/com/fivucsas/mobile/android/ui/screen/NfcReadScreen.kt
+++ b/androidApp/src/main/kotlin/com/fivucsas/mobile/android/ui/screen/NfcReadScreen.kt
@@ -751,26 +751,42 @@ private fun VerifyAuthenticitySection(
}
}
is AuthenticityUiState.NotAuthentic -> {
- Column {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Icon(
- Icons.Default.Error,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.error
- )
- Spacer(modifier = Modifier.width(8.dp))
- Text(
- s(StringKey.NFC_AUTHENTICITY_NOT_AUTHENTIC),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.error
- )
- }
- authState.reasonCode?.let {
- Text(
- it,
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
+ // Known "the operator hasn't loaded the CSCA trust store yet" case —
+ // the server returns 422 with reasonCode=NO_TRUST_STORE (and errorCode
+ // NFC_PA_NOT_AUTHENTIC). This is NOT a forged chip; it just means the
+ // issuer's certificate isn't configured. Show a calm, non-scary message
+ // instead of a red "could not be confirmed" + a raw reason code. We do
+ // NOT claim the chip is authentic — we only stop alarming the user.
+ val isTrustStoreUnavailable = authState.reasonCode == "NO_TRUST_STORE" ||
+ authState.reasonCode == "NFC_PA_NOT_AUTHENTIC"
+ if (isTrustStoreUnavailable) {
+ Text(
+ s(StringKey.NFC_AUTHENTICITY_PA_UNAVAILABLE),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ } else {
+ Column {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ Icons.Default.Error,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ s(StringKey.NFC_AUTHENTICITY_NOT_AUTHENTIC),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ authState.reasonCode?.let {
+ Text(
+ it,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
}
}
}
diff --git a/shared/src/commonMain/kotlin/com/fivucsas/shared/data/local/TokenManager.kt b/shared/src/commonMain/kotlin/com/fivucsas/shared/data/local/TokenManager.kt
index 400ad3dc..f17cc6d6 100644
--- a/shared/src/commonMain/kotlin/com/fivucsas/shared/data/local/TokenManager.kt
+++ b/shared/src/commonMain/kotlin/com/fivucsas/shared/data/local/TokenManager.kt
@@ -1,6 +1,13 @@
package com.fivucsas.shared.data.local
import com.fivucsas.shared.domain.repository.AuthTokens
+import kotlin.io.encoding.Base64
+import kotlin.io.encoding.ExperimentalEncodingApi
+import kotlinx.datetime.Clock
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import kotlinx.serialization.json.longOrNull
/**
* Token Manager - Centralized token management
@@ -71,10 +78,59 @@ class TokenManager(
}
/**
- * Check if user is authenticated
+ * Check if user is authenticated.
+ *
+ * Returns true when EITHER:
+ * - a non-expired access token is present, OR
+ * - the access token is missing/expired but a refresh token exists.
+ *
+ * The second case is what keeps a returning user signed in after the short
+ * (15-min) access-token lifetime: previously this checked token *presence*
+ * only, so the app routed to the dashboard and the first data call 401'd into
+ * a perceived logout; now the access token is allowed to be stale because the
+ * NetworkModule 401 handler transparently refreshes it and retries. We never
+ * grant access on a missing/expired access token WITHOUT a refresh token.
*/
fun isAuthenticated(): Boolean {
- return getAccessToken() != null
+ val accessToken = getAccessToken() ?: return getRefreshToken() != null
+ if (!isAccessTokenExpired(accessToken)) return true
+ // Access token is expired — still authenticated if we can silently refresh.
+ return getRefreshToken() != null
+ }
+
+ /**
+ * True when the access token's `exp` claim is in the past (with a small skew
+ * margin). If the token can't be parsed as a JWT we treat it as NOT expired
+ * (fail-open) so opaque/unknown token formats keep working — the server's 401
+ * remains the ultimate authority and drives the refresh path.
+ */
+ @OptIn(ExperimentalEncodingApi::class)
+ fun isAccessTokenExpired(token: String, skewSeconds: Long = 30): Boolean {
+ val expSeconds = jwtExpirySeconds(token) ?: return false
+ val nowSeconds = Clock.System.now().toEpochMilliseconds() / 1000
+ return nowSeconds >= (expSeconds - skewSeconds)
+ }
+
+ /**
+ * Decode a JWT's `exp` claim (epoch seconds) from its payload without
+ * verifying the signature — verification is the server's job; we only need
+ * the expiry to decide whether to pre-emptively refresh on startup. Returns
+ * null for non-JWT/unparseable tokens.
+ */
+ @OptIn(ExperimentalEncodingApi::class)
+ private fun jwtExpirySeconds(token: String): Long? {
+ return try {
+ val parts = token.split(".")
+ if (parts.size < 2) return null
+ // JWT payloads are base64url, normally WITHOUT padding;
+ // ABSENT_OPTIONAL tolerates either padded or unpadded input.
+ val payloadBytes = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL)
+ .decode(parts[1])
+ val payload = Json.parseToJsonElement(payloadBytes.decodeToString()).jsonObject
+ payload["exp"]?.jsonPrimitive?.longOrNull
+ } catch (_: Exception) {
+ null
+ }
}
/**
diff --git a/shared/src/commonMain/kotlin/com/fivucsas/shared/data/remote/api/ApproveLoginApiImpl.kt b/shared/src/commonMain/kotlin/com/fivucsas/shared/data/remote/api/ApproveLoginApiImpl.kt
index 0507afce..90ed39ef 100644
--- a/shared/src/commonMain/kotlin/com/fivucsas/shared/data/remote/api/ApproveLoginApiImpl.kt
+++ b/shared/src/commonMain/kotlin/com/fivucsas/shared/data/remote/api/ApproveLoginApiImpl.kt
@@ -4,11 +4,14 @@ import com.fivucsas.shared.data.remote.dto.ApproveLoginDecisionDto
import com.fivucsas.shared.data.remote.dto.PendingApproveLoginDto
import io.ktor.client.HttpClient
import io.ktor.client.call.body
+import io.ktor.client.plugins.ResponseException
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
+import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
+import io.ktor.http.isSuccess
class ApproveLoginApiImpl(
private val client: HttpClient
@@ -23,9 +26,21 @@ class ApproveLoginApiImpl(
}
override suspend fun decide(sessionId: String, decision: String, matchNumber: String?) {
- client.post("$BASE_PATH/session/$sessionId/decide") {
+ // Mirrors the QR-approve fix: this call read no body, so a non-2xx
+ // (e.g. an expired bearer → 401/403) was silently swallowed — the phone
+ // showed the login as approved while the server session stayed PENDING
+ // and the web/originating login never advanced. Fail explicitly so the
+ // repository/ViewModel surface the real error (the 401 path also triggers
+ // a transparent refresh+retry in NetworkModule).
+ val response = client.post("$BASE_PATH/session/$sessionId/decide") {
contentType(ContentType.Application.Json)
setBody(ApproveLoginDecisionDto(decision = decision, matchNumber = matchNumber))
}
+ if (!response.status.isSuccess()) {
+ throw ResponseException(
+ response,
+ "Approve-login decision failed: ${response.status} ${response.bodyAsText()}",
+ )
+ }
}
}
diff --git a/shared/src/commonMain/kotlin/com/fivucsas/shared/data/remote/api/QrLoginApiImpl.kt b/shared/src/commonMain/kotlin/com/fivucsas/shared/data/remote/api/QrLoginApiImpl.kt
index fab51897..f4153c90 100644
--- a/shared/src/commonMain/kotlin/com/fivucsas/shared/data/remote/api/QrLoginApiImpl.kt
+++ b/shared/src/commonMain/kotlin/com/fivucsas/shared/data/remote/api/QrLoginApiImpl.kt
@@ -5,11 +5,14 @@ import com.fivucsas.shared.data.remote.dto.QrLoginCreateSessionRequestDto
import com.fivucsas.shared.data.remote.dto.QrLoginSessionResponseDto
import io.ktor.client.HttpClient
import io.ktor.client.call.body
+import io.ktor.client.plugins.ResponseException
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
+import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
+import io.ktor.http.isSuccess
class QrLoginApiImpl(
private val client: HttpClient
@@ -31,9 +34,26 @@ class QrLoginApiImpl(
}
override suspend fun approveSession(sessionId: String, request: QrLoginApproveRequestDto) {
- client.post("$BASE_PATH/$sessionId/approve") {
+ // The web login poller only advances once the SERVER session flips to
+ // APPROVED. Unlike createSession/getSession (which call .body() and so
+ // throw on a non-2xx via the deserializer), this call read no body and
+ // silently swallowed a 401/403 (e.g. an expired bearer) — the phone UI
+ // flipped to "APPROVED" while the server stayed PENDING and the browser
+ // waited forever. Explicitly fail on a non-success status so the
+ // repository/ViewModel surface the real error (and the 401 path triggers
+ // a transparent refresh+retry in NetworkModule).
+ val response = client.post("$BASE_PATH/$sessionId/approve") {
contentType(ContentType.Application.Json)
setBody(request)
}
+ if (!response.status.isSuccess()) {
+ // Include the numeric status in the message so the shared ErrorMapper
+ // (which matches on "401"/"403"/… substrings) maps it to a useful
+ // user message instead of a silent false "approved".
+ throw ResponseException(
+ response,
+ "QR approve failed: ${response.status} ${response.bodyAsText()}",
+ )
+ }
}
}
diff --git a/shared/src/commonMain/kotlin/com/fivucsas/shared/di/NetworkModule.kt b/shared/src/commonMain/kotlin/com/fivucsas/shared/di/NetworkModule.kt
index 0b67165d..1b67caee 100644
--- a/shared/src/commonMain/kotlin/com/fivucsas/shared/di/NetworkModule.kt
+++ b/shared/src/commonMain/kotlin/com/fivucsas/shared/di/NetworkModule.kt
@@ -37,19 +37,20 @@ import com.fivucsas.shared.data.remote.dto.OAuthTokenResponseDto
import com.fivucsas.shared.data.remote.dto.toModel
import io.ktor.client.HttpClient
import io.ktor.client.call.body
-import io.ktor.client.plugins.HttpResponseValidator
+import io.ktor.client.plugins.HttpSend
import io.ktor.client.plugins.HttpTimeout
+import io.ktor.client.plugins.plugin
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.plugins.logging.DEFAULT
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
+import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.forms.submitForm
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
-import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
@@ -84,6 +85,95 @@ private val refreshMutex = Mutex()
*/
private const val OAUTH_MOBILE_CLIENT_ID = "fivucsas-mobile"
+/**
+ * Auth endpoints that must NOT trigger a refresh-on-401 (avoids infinite loops);
+ * includes the OAuth token endpoint so a 401 from the refresh_token grant itself
+ * can't recurse into another refresh.
+ */
+private fun isAuthEndpoint(url: String): Boolean =
+ url.contains("/auth/login") || url.contains("/auth/refresh") ||
+ url.contains("/auth/logout") || url.contains("/oauth2/token")
+
+/**
+ * Perform a single silent token refresh under [refreshMutex].
+ *
+ * Returns true if, after this call, a usable access token exists (either because
+ * we refreshed it, or because a concurrent 401 handler already did). Returns
+ * false if refresh was impossible/failed (tokens are then cleared → re-login).
+ *
+ * [staleAccessToken] is the `Authorization` header the failed request carried; if
+ * the stored access token already differs we skip the refresh (a concurrent
+ * handler rotated the tokens) and report success so the caller retries with the
+ * fresh token. This is the logic that USED to live in the HttpResponseValidator;
+ * it is now reusable from the HttpSend interceptor so the original request can be
+ * transparently re-fired (closing the old "caller receives the 401" TODO).
+ */
+private suspend fun refreshAccessToken(
+ client: HttpClient,
+ tokenManager: TokenManager,
+ staleAccessToken: String?,
+): Boolean = refreshMutex.withLock {
+ // A refresh already happened while we waited for the lock.
+ val currentAccessToken = tokenManager.getAccessToken()
+ if (currentAccessToken != null && staleAccessToken != "Bearer $currentAccessToken") {
+ // Tokens were rotated by a concurrent 401 handler; reuse them.
+ return@withLock true
+ }
+
+ val refreshToken = tokenManager.getRefreshToken() ?: return@withLock false
+
+ try {
+ if (tokenManager.isOAuthSession()) {
+ // Hosted-first OAuth session — renew via the OAuth refresh-token
+ // grant (RFC 6749 §6) at the OAuth token endpoint, form-url-encoded.
+ val refreshResponse = client.submitForm(
+ url = ApiConfig.identityBaseUrl + "/oauth2/token",
+ formParameters = Parameters.build {
+ append("grant_type", "refresh_token")
+ append("refresh_token", refreshToken)
+ append("client_id", OAUTH_MOBILE_CLIENT_ID)
+ },
+ )
+ if (refreshResponse.status == HttpStatusCode.OK) {
+ val oauth = refreshResponse.body().toModel()
+ // The OAuth refresh response carries no profile fields — re-apply
+ // the cached identity so role/tenant context survives the rotation.
+ tokenManager.updateTokens(
+ oauth.copy(
+ role = tokenManager.getRole() ?: oauth.role,
+ userName = tokenManager.getUserName() ?: "",
+ userEmail = tokenManager.getUserEmail() ?: "",
+ userId = tokenManager.getUserId() ?: "",
+ tenantId = tokenManager.getTenantId() ?: "",
+ ),
+ )
+ true
+ } else {
+ tokenManager.clearTokens()
+ false
+ }
+ } else {
+ // Legacy password/MFA session — the original /auth/refresh JSON path.
+ val refreshResponse = client.post(ApiConfig.identityBaseUrl + "/auth/refresh") {
+ contentType(ContentType.Application.Json)
+ setBody(mapOf("refreshToken" to refreshToken))
+ }
+ if (refreshResponse.status == HttpStatusCode.OK) {
+ val authResponse = refreshResponse.body()
+ tokenManager.updateTokens(authResponse.toModel())
+ true
+ } else {
+ // Refresh failed — clear tokens to force re-login.
+ tokenManager.clearTokens()
+ false
+ }
+ }
+ } catch (_: Exception) {
+ tokenManager.clearTokens()
+ false
+ }
+}
+
/**
* Network module - Provides HTTP clients and API clients
*
@@ -153,95 +243,36 @@ val networkModule = module {
stepUpTokenManager.getToken()?.let { header("X-Step-Up-Token", it) }
}
- // Automatic token refresh on 401 responses
- HttpResponseValidator {
- validateResponse { response: HttpResponse ->
- if (response.status == HttpStatusCode.Unauthorized) {
- val url = response.call.request.url.toString()
- // Don't attempt refresh on auth endpoints to avoid infinite loops.
- // Includes the OAuth token endpoint so a 401 from the
- // refresh_token grant itself can't recurse into another refresh.
- if (url.contains("/auth/login") || url.contains("/auth/refresh") ||
- url.contains("/auth/logout") || url.contains("/oauth2/token")) {
- return@validateResponse
- }
-
- // Snapshot the access token that this request used. If
- // it differs from the current one by the time we hold
- // the lock, another coroutine already refreshed and we
- // must NOT refresh again (that would burn the just-issued
- // rotated refresh token and log the user out).
- val staleAccessToken = response.call.request.headers[HttpHeaders.Authorization]
-
- refreshMutex.withLock {
- // A refresh already happened while we waited for the lock.
- val currentAccessToken = tokenManager.getAccessToken()
- if (currentAccessToken != null &&
- staleAccessToken != "Bearer $currentAccessToken") {
- // Tokens were rotated by a concurrent 401 handler;
- // reuse them on the transparent retry below.
- return@withLock
- }
-
- val refreshToken = tokenManager.getRefreshToken() ?: return@withLock
-
- try {
- // Reuse the call's client; the recursion guard above
- // prevents the refresh POST from re-entering.
- if (tokenManager.isOAuthSession()) {
- // Hosted-first OAuth session — renew via the
- // OAuth refresh-token grant (RFC 6749 §6) at the
- // OAuth token endpoint, form-url-encoded.
- val refreshResponse = response.call.client.submitForm(
- url = ApiConfig.identityBaseUrl + "/oauth2/token",
- formParameters = Parameters.build {
- append("grant_type", "refresh_token")
- append("refresh_token", refreshToken)
- append("client_id", OAUTH_MOBILE_CLIENT_ID)
- },
- )
- if (refreshResponse.status == HttpStatusCode.OK) {
- val oauth = refreshResponse.body().toModel()
- // The OAuth refresh response carries no profile
- // fields — re-apply the cached identity so role/
- // tenant context survives the rotation.
- tokenManager.updateTokens(
- oauth.copy(
- role = tokenManager.getRole() ?: oauth.role,
- userName = tokenManager.getUserName() ?: "",
- userEmail = tokenManager.getUserEmail() ?: "",
- userId = tokenManager.getUserId() ?: "",
- tenantId = tokenManager.getTenantId() ?: "",
- ),
- )
- } else {
- tokenManager.clearTokens()
- }
- } else {
- // Legacy password/MFA session — the original
- // /auth/refresh JSON path (unchanged).
- val refreshResponse = response.call.client.post(ApiConfig.identityBaseUrl + "/auth/refresh") {
- contentType(ContentType.Application.Json)
- setBody(mapOf("refreshToken" to refreshToken))
- }
- if (refreshResponse.status == HttpStatusCode.OK) {
- val authResponse = refreshResponse.body()
- tokenManager.updateTokens(authResponse.toModel())
- } else {
- // Refresh failed — clear tokens to force re-login
- tokenManager.clearTokens()
- }
- }
- } catch (_: Exception) {
- tokenManager.clearTokens()
- }
- }
- // TODO: transparently retry the original request with the
- // refreshed access token. Currently the caller receives the
- // 401 and must retry; the next request picks up the new token
- // via defaultRequest. The mutex is the load-bearing fix here.
- }
+ }.also { httpClient ->
+ // Transparent refresh-on-401 + RETRY of the original request.
+ //
+ // We use the HttpSend plugin (part of ktor-client-core — no extra
+ // dependency; the project deliberately avoids io.ktor:ktor-client-auth,
+ // see desktopApp RefreshInterceptor) because, unlike HttpResponseValidator,
+ // its interceptor CAN re-execute the request. This closes the old TODO
+ // where the caller received the raw 401 and the session appeared to
+ // die after the ~15-min access-token lifetime: now the access token is
+ // refreshed under a mutex and the SAME request is re-fired once with the
+ // fresh bearer (defaultRequest re-reads the stored token), so the call
+ // succeeds transparently instead of surfacing a logout.
+ httpClient.plugin(HttpSend).intercept { request: HttpRequestBuilder ->
+ val originalCall = execute(request)
+ val url = originalCall.request.url.toString()
+ if (originalCall.response.status != HttpStatusCode.Unauthorized || isAuthEndpoint(url)) {
+ return@intercept originalCall
}
+
+ // Snapshot the access token this request carried so the refresh
+ // helper can detect a concurrent rotation and avoid double-refresh.
+ val staleAccessToken = originalCall.request.headers[HttpHeaders.Authorization]
+ val refreshed = refreshAccessToken(httpClient, tokenManager, staleAccessToken)
+ if (!refreshed) {
+ return@intercept originalCall
+ }
+
+ // Re-fire the original request once. defaultRequest stamps the
+ // freshly-stored access token onto the retry.
+ execute(request)
}
}
}
@@ -344,4 +375,12 @@ val networkModule = module {
single {
com.fivucsas.shared.data.remote.api.NfcAuthenticityApiImpl(get(named("identityClient")))
}
+
+ // Audit-log / "my activity" API — #9: was NEVER registered, so ActivityHistoryViewModel
+ // and AuditLogDashboardViewModel could not be created → Koin NoDefinitionFoundException
+ // (FATAL crash on opening Activity History / the audit-log dashboard). Added in #83 but
+ // its DI binding was omitted; this restores it.
+ single {
+ com.fivucsas.shared.data.remote.api.AuditLogApiImpl(get(named("identityClient")))
+ }
}
diff --git a/shared/src/commonMain/kotlin/com/fivucsas/shared/di/RepositoryModule.kt b/shared/src/commonMain/kotlin/com/fivucsas/shared/di/RepositoryModule.kt
index fc8fd948..51ef5a33 100644
--- a/shared/src/commonMain/kotlin/com/fivucsas/shared/di/RepositoryModule.kt
+++ b/shared/src/commonMain/kotlin/com/fivucsas/shared/di/RepositoryModule.kt
@@ -229,6 +229,15 @@ val repositoryModule = module {
)
}
+ // Audit-log / "my activity" Repository — #9: required by ActivityHistoryViewModel and
+ // AuditLogDashboardViewModel but was never registered (companion to the AuditLogApi
+ // binding in NetworkModule). Without it, opening Activity History hard-crashes the app.
+ single {
+ com.fivucsas.shared.data.repository.AuditLogRepositoryImpl(
+ auditLogApi = get()
+ )
+ }
+
// Offline Cache
single { OfflineCache(storage = get()) }
}
diff --git a/shared/src/commonMain/kotlin/com/fivucsas/shared/i18n/StringResources.kt b/shared/src/commonMain/kotlin/com/fivucsas/shared/i18n/StringResources.kt
index cf92fd7a..430c3ef9 100644
--- a/shared/src/commonMain/kotlin/com/fivucsas/shared/i18n/StringResources.kt
+++ b/shared/src/commonMain/kotlin/com/fivucsas/shared/i18n/StringResources.kt
@@ -483,6 +483,7 @@ enum class StringKey {
NFC_AUTHENTICITY_IN_PROGRESS,
NFC_AUTHENTICITY_AUTHENTIC,
NFC_AUTHENTICITY_NOT_AUTHENTIC,
+ NFC_AUTHENTICITY_PA_UNAVAILABLE,
// Linked accounts + workspace switcher (account-linking parity)
LINKED_ACCOUNTS_TITLE,
@@ -738,6 +739,7 @@ enum class StringKey {
DASH_JOIN_TENANT,
DASH_ADD_CARD,
DASH_NFC_READER,
+ DASH_LOGIN_REQUESTS,
DASH_SESSION_FALLBACK,
DASH_ENROLLMENT_STATUS,
DASH_ENROLL_PROMPT,
@@ -1792,6 +1794,7 @@ private val enStrings = mapOf(
StringKey.NFC_AUTHENTICITY_IN_PROGRESS to "Verifying authenticity…",
StringKey.NFC_AUTHENTICITY_AUTHENTIC to "Document is authentic",
StringKey.NFC_AUTHENTICITY_NOT_AUTHENTIC to "Authenticity could not be confirmed",
+ StringKey.NFC_AUTHENTICITY_PA_UNAVAILABLE to "Chip authenticity check unavailable — the issuer's certificate isn't configured yet. The card was read and its data is valid.",
StringKey.LINKED_ACCOUNTS_TITLE to "Linked Accounts",
StringKey.LINKED_ACCOUNTS_DESCRIPTION to "Verified emails and tenant memberships linked to your identity.",
StringKey.LINKED_ACCOUNTS_EMAILS to "Emails",
@@ -2046,6 +2049,7 @@ private val enStrings = mapOf(
StringKey.DASH_JOIN_TENANT to "Join a Tenant",
StringKey.DASH_ADD_CARD to "Add card",
StringKey.DASH_NFC_READER to "NFC Reader",
+ StringKey.DASH_LOGIN_REQUESTS to "Login Requests",
StringKey.DASH_SESSION_FALLBACK to "Session",
StringKey.DASH_ENROLLMENT_STATUS to "Enrollment Status",
StringKey.DASH_ENROLL_PROMPT to "Enroll your face to enable biometric verification.",
@@ -2151,15 +2155,15 @@ private val enStrings = mapOf(
// NfcReadScreen
StringKey.NFCREAD_TITLE to "NFC Card Reader",
StringKey.NFCREAD_DOC_READER_HEADER to "Identity Document Reader",
- StringKey.NFCREAD_DOC_READER_SUBTITLE to "Enter MRZ data to read passport or eID chip",
+ StringKey.NFCREAD_DOC_READER_SUBTITLE to "Step ① fill the MRZ (scan or type), then step ② read the chip",
StringKey.NFCREAD_FIELD_DOCUMENT_NUMBER to "Document Number",
StringKey.NFCREAD_FIELD_DOCUMENT_NUMBER_HINT to "e.g. A12345678",
- StringKey.NFCREAD_FIELD_DATE_OF_BIRTH to "Date of Birth",
- StringKey.NFCREAD_FIELD_DATE_OF_EXPIRY to "Date of Expiry",
+ StringKey.NFCREAD_FIELD_DATE_OF_BIRTH to "Date of Birth (YYMMDD)",
+ StringKey.NFCREAD_FIELD_DATE_OF_EXPIRY to "Date of Expiry (YYMMDD)",
StringKey.NFCREAD_DATE_HINT_YYMMDD to "YYMMDD",
- StringKey.NFCREAD_SCAN_WITH_MRZ to "Scan with MRZ",
- StringKey.NFCREAD_SCAN_MRZ_CAMERA to "Scan MRZ with camera",
- StringKey.NFCREAD_SCAN_ANY_CARD to "Scan Any NFC Card",
+ StringKey.NFCREAD_SCAN_WITH_MRZ to "② Read chip (NFC)",
+ StringKey.NFCREAD_SCAN_MRZ_CAMERA to "① Scan MRZ with camera (auto-fill)",
+ StringKey.NFCREAD_SCAN_ANY_CARD to "Read any NFC card (no MRZ)",
StringKey.NFCREAD_READY_TITLE to "Ready to Scan",
StringKey.NFCREAD_READY_SUBTITLE to "Hold your card against the back of your phone",
StringKey.NFCREAD_READING_TITLE to "Reading Card...",
@@ -3091,6 +3095,7 @@ private val trStrings = mapOf(
StringKey.NFC_AUTHENTICITY_IN_PROGRESS to "Orijinallik doğrulanıyor…",
StringKey.NFC_AUTHENTICITY_AUTHENTIC to "Belge orijinal",
StringKey.NFC_AUTHENTICITY_NOT_AUTHENTIC to "Orijinallik doğrulanamadı",
+ StringKey.NFC_AUTHENTICITY_PA_UNAVAILABLE to "Çip orijinallik kontrolü kullanılamıyor — veren kurumun sertifikası henüz yapılandırılmadı. Kart okundu ve verileri geçerli.",
StringKey.LINKED_ACCOUNTS_TITLE to "Bağlı Hesaplar",
StringKey.LINKED_ACCOUNTS_DESCRIPTION to "Kimliğinize bağlı doğrulanmış e-postalar ve kiracı üyelikleri.",
StringKey.LINKED_ACCOUNTS_EMAILS to "E-postalar",
@@ -3345,6 +3350,7 @@ private val trStrings = mapOf(
StringKey.DASH_JOIN_TENANT to "Kuruluşa Katıl",
StringKey.DASH_ADD_CARD to "Kart Ekle",
StringKey.DASH_NFC_READER to "NFC Okuyucu",
+ StringKey.DASH_LOGIN_REQUESTS to "Giriş İstekleri",
StringKey.DASH_SESSION_FALLBACK to "Oturum",
StringKey.DASH_ENROLLMENT_STATUS to "Kayıt Durumu",
StringKey.DASH_ENROLL_PROMPT to "Biyometrik doğrulamayı etkinleştirmek için yüzünüzü kaydedin.",
@@ -3450,15 +3456,15 @@ private val trStrings = mapOf(
// NfcReadScreen
StringKey.NFCREAD_TITLE to "NFC Kart Okuyucu",
StringKey.NFCREAD_DOC_READER_HEADER to "Kimlik Belgesi Okuyucu",
- StringKey.NFCREAD_DOC_READER_SUBTITLE to "Pasaport veya e-kimlik çipini okumak için MRZ verisini girin",
+ StringKey.NFCREAD_DOC_READER_SUBTITLE to "Önce ① MRZ'yi doldurun (tara veya yaz), sonra ② çipi okuyun",
StringKey.NFCREAD_FIELD_DOCUMENT_NUMBER to "Belge Numarası",
StringKey.NFCREAD_FIELD_DOCUMENT_NUMBER_HINT to "örn. A12345678",
- StringKey.NFCREAD_FIELD_DATE_OF_BIRTH to "Doğum Tarihi",
- StringKey.NFCREAD_FIELD_DATE_OF_EXPIRY to "Son Geçerlilik Tarihi",
+ StringKey.NFCREAD_FIELD_DATE_OF_BIRTH to "Doğum Tarihi (YYAAGG)",
+ StringKey.NFCREAD_FIELD_DATE_OF_EXPIRY to "Son Geçerlilik Tarihi (YYAAGG)",
StringKey.NFCREAD_DATE_HINT_YYMMDD to "YYAAGG",
- StringKey.NFCREAD_SCAN_WITH_MRZ to "MRZ ile Tara",
- StringKey.NFCREAD_SCAN_MRZ_CAMERA to "MRZ'yi kamerayla tara",
- StringKey.NFCREAD_SCAN_ANY_CARD to "Herhangi Bir NFC Kartı Tara",
+ StringKey.NFCREAD_SCAN_WITH_MRZ to "② Çipi oku (NFC)",
+ StringKey.NFCREAD_SCAN_MRZ_CAMERA to "① MRZ'yi kamerayla tara (otomatik doldur)",
+ StringKey.NFCREAD_SCAN_ANY_CARD to "Herhangi bir NFC kartı oku (MRZ'siz)",
StringKey.NFCREAD_READY_TITLE to "Taramaya Hazır",
StringKey.NFCREAD_READY_SUBTITLE to "Kartınızı telefonunuzun arkasına tutun",
StringKey.NFCREAD_READING_TITLE to "Kart Okunuyor...",
diff --git a/shared/src/commonTest/kotlin/com/fivucsas/shared/data/local/TokenManagerAuthExpiryTest.kt b/shared/src/commonTest/kotlin/com/fivucsas/shared/data/local/TokenManagerAuthExpiryTest.kt
new file mode 100644
index 00000000..421b57dc
--- /dev/null
+++ b/shared/src/commonTest/kotlin/com/fivucsas/shared/data/local/TokenManagerAuthExpiryTest.kt
@@ -0,0 +1,103 @@
+package com.fivucsas.shared.data.local
+
+import com.fivucsas.shared.domain.repository.AuthTokens
+import kotlin.io.encoding.Base64
+import kotlin.io.encoding.ExperimentalEncodingApi
+import kotlinx.datetime.Clock
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+/**
+ * Pre-demo 2026-06-03 — guards the session-expiry fix (#2).
+ *
+ * isAuthenticated() used to check token PRESENCE only, so after the ~15-min
+ * access-token lifetime the app routed to the dashboard and the first data call
+ * 401'd into a perceived logout. It now tolerates an expired access token when a
+ * refresh token exists (the NetworkModule 401 handler refreshes + retries), and
+ * still rejects a missing/expired token with no refresh token.
+ */
+class TokenManagerAuthExpiryTest {
+
+ @OptIn(ExperimentalEncodingApi::class)
+ private fun jwtWithExp(expEpochSeconds: Long): String {
+ val header = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT)
+ .encode("""{"alg":"none","typ":"JWT"}""".encodeToByteArray())
+ val payload = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT)
+ .encode("""{"sub":"u1","exp":$expEpochSeconds}""".encodeToByteArray())
+ return "$header.$payload.sig"
+ }
+
+ private fun nowSeconds() = Clock.System.now().toEpochMilliseconds() / 1000
+
+ private fun manager() = TokenManager(InMemoryTokenStorage())
+
+ private fun tokens(access: String, refresh: String) = AuthTokens(
+ accessToken = access,
+ refreshToken = refresh,
+ expiresIn = 900,
+ role = "USER",
+ userName = "Test",
+ userEmail = "t@example.com",
+ userId = "u1",
+ tenantId = "11111111-1111-1111-1111-111111111111",
+ )
+
+ @Test
+ fun `fresh access token is authenticated`() {
+ val tm = manager()
+ tm.saveTokens(tokens(jwtWithExp(nowSeconds() + 3600), "refresh-1"))
+ assertTrue(tm.isAuthenticated())
+ assertFalse(tm.isAccessTokenExpired(tm.getAccessToken()!!))
+ }
+
+ @Test
+ fun `expired access token WITH refresh token is still authenticated`() {
+ val tm = manager()
+ tm.saveTokens(tokens(jwtWithExp(nowSeconds() - 3600), "refresh-1"))
+ assertTrue(tm.isAccessTokenExpired(tm.getAccessToken()!!))
+ // The whole point of #2: don't force re-login when a silent refresh is possible.
+ assertTrue(tm.isAuthenticated())
+ }
+
+ @Test
+ fun `no tokens at all is not authenticated`() {
+ val tm = manager()
+ assertFalse(tm.isAuthenticated())
+ }
+
+ @Test
+ fun `opaque non-JWT token is treated as not-expired (fail-open)`() {
+ val tm = manager()
+ // Server's 401 remains the authority; we don't lock out unknown formats.
+ assertFalse(tm.isAccessTokenExpired("not-a-jwt"))
+ }
+
+ private class InMemoryTokenStorage : TokenStorage {
+ private val store = mutableMapOf()
+ override fun saveToken(token: String) { store["token"] = token }
+ override fun getToken(): String? = store["token"]
+ override fun clearToken() { store.remove("token") }
+ override fun saveRefreshToken(token: String) { store["refresh"] = token }
+ override fun getRefreshToken(): String? = store["refresh"]
+ override fun clearRefreshToken() { store.remove("refresh") }
+ override fun saveRole(role: String) { store["role"] = role }
+ override fun getRole(): String? = store["role"]
+ override fun clearRole() { store.remove("role") }
+ override fun saveUserName(name: String) { store["userName"] = name }
+ override fun getUserName(): String? = store["userName"]
+ override fun clearUserName() { store.remove("userName") }
+ override fun saveUserEmail(email: String) { store["userEmail"] = email }
+ override fun getUserEmail(): String? = store["userEmail"]
+ override fun clearUserEmail() { store.remove("userEmail") }
+ override fun saveUserId(id: String) { store["userId"] = id }
+ override fun getUserId(): String? = store["userId"]
+ override fun clearUserId() { store.remove("userId") }
+ override fun saveTenantId(tenantId: String) { store["tenantId"] = tenantId }
+ override fun getTenantId(): String? = store["tenantId"]
+ override fun clearTenantId() { store.remove("tenantId") }
+ override fun saveOAuthSession(oauth: Boolean) { store["oauth"] = oauth.toString() }
+ override fun getOAuthSession(): Boolean = store["oauth"]?.toBoolean() ?: false
+ override fun clearOAuthSession() { store.remove("oauth") }
+ }
+}
diff --git a/shared/src/commonTest/kotlin/com/fivucsas/shared/data/remote/api/QrApproveLoginStatusCheckTest.kt b/shared/src/commonTest/kotlin/com/fivucsas/shared/data/remote/api/QrApproveLoginStatusCheckTest.kt
new file mode 100644
index 00000000..a1d04eb4
--- /dev/null
+++ b/shared/src/commonTest/kotlin/com/fivucsas/shared/data/remote/api/QrApproveLoginStatusCheckTest.kt
@@ -0,0 +1,108 @@
+package com.fivucsas.shared.data.remote.api
+
+import com.fivucsas.shared.data.remote.dto.QrLoginApproveRequestDto
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.mock.MockEngine
+import io.ktor.client.engine.mock.respond
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.defaultRequest
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.headersOf
+import io.ktor.http.HttpHeaders
+import io.ktor.serialization.kotlinx.json.json
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.json.Json
+import kotlin.test.Test
+import kotlin.test.assertTrue
+import kotlin.test.fail
+
+/**
+ * Pre-demo 2026-06-03 — regression guard for the "approved on phone, browser
+ * waits forever" bug (#1 + approve-login).
+ *
+ * QrLoginApiImpl.approveSession and ApproveLoginApiImpl.decide previously POSTed
+ * WITHOUT reading the response, so a non-2xx (e.g. an expired bearer → 401/403)
+ * was silently swallowed: the phone flipped to APPROVED while the server session
+ * stayed PENDING and the originating (web) login never advanced. Both now throw
+ * on a non-success status so the repository/ViewModel surface the real error.
+ */
+class QrApproveLoginStatusCheckTest {
+
+ private val json = Json { ignoreUnknownKeys = true }
+
+ private fun client(handler: io.ktor.client.engine.mock.MockRequestHandler): HttpClient {
+ val engine = MockEngine(handler)
+ return HttpClient(engine) {
+ install(ContentNegotiation) { json(json) }
+ defaultRequest { url("https://test.example.com/api/v1/") }
+ }
+ }
+
+ // ---- QR approve ----
+
+ @Test
+ fun `qr approveSession succeeds on 200`() = runTest {
+ val api = QrLoginApiImpl(client { _ -> respond("", HttpStatusCode.OK) })
+ // Should not throw.
+ api.approveSession("sess-1", QrLoginApproveRequestDto(approverPlatform = "MOBILE"))
+ }
+
+ @Test
+ fun `qr approveSession throws on 401 (expired bearer)`() = runTest {
+ val api = QrLoginApiImpl(client { _ ->
+ respond(
+ content = "{\"error\":\"UNAUTHORIZED\"}",
+ status = HttpStatusCode.Unauthorized,
+ headers = headersOf(HttpHeaders.ContentType, "application/json"),
+ )
+ })
+ try {
+ api.approveSession("sess-1", QrLoginApproveRequestDto(approverPlatform = "MOBILE"))
+ fail("Expected approveSession to throw on 401 instead of swallowing it")
+ } catch (e: Exception) {
+ assertTrue("401" in (e.message ?: ""), "Status should surface in message: ${e.message}")
+ }
+ }
+
+ @Test
+ fun `qr approveSession throws on 403`() = runTest {
+ val api = QrLoginApiImpl(client { _ -> respond("forbidden", HttpStatusCode.Forbidden) })
+ try {
+ api.approveSession("sess-1", QrLoginApproveRequestDto(approverPlatform = "MOBILE"))
+ fail("Expected approveSession to throw on 403")
+ } catch (_: Exception) {
+ // expected
+ }
+ }
+
+ // ---- approve-login decide ----
+
+ @Test
+ fun `approve-login decide succeeds on 200`() = runTest {
+ val api = ApproveLoginApiImpl(client { _ -> respond("", HttpStatusCode.OK) })
+ // Should not throw.
+ api.decide("sess-2", "allow", "42")
+ }
+
+ @Test
+ fun `approve-login decide throws on 401 (expired bearer)`() = runTest {
+ val api = ApproveLoginApiImpl(client { _ -> respond("nope", HttpStatusCode.Unauthorized) })
+ try {
+ api.decide("sess-2", "allow", "42")
+ fail("Expected decide to throw on 401 instead of swallowing it")
+ } catch (e: Exception) {
+ assertTrue("401" in (e.message ?: ""), "Status should surface in message: ${e.message}")
+ }
+ }
+
+ @Test
+ fun `approve-login decide throws on 403`() = runTest {
+ val api = ApproveLoginApiImpl(client { _ -> respond("forbidden", HttpStatusCode.Forbidden) })
+ try {
+ api.decide("sess-2", "deny", null)
+ fail("Expected decide to throw on 403")
+ } catch (_: Exception) {
+ // expected
+ }
+ }
+}