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 + } + } +}