From 28363578d076c77ba3c65ea4448b968db9225028 Mon Sep 17 00:00:00 2001 From: Vincent Te Date: Wed, 29 Apr 2026 17:26:46 +0200 Subject: [PATCH 1/4] feat: Add securityScore and lastPasswordUpdate in AccountEntity # Conflicts: # multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt # Conflicts: # multiplatform-lib/src/commonMain/kotlin/models/migration/user/preferences/Preferences.kt --- .../screen/accountlist/AccountListScreen.kt | 5 ++++ .../accountlist/AccountListViewModel.kt | 22 ++++++++++---- .../com/infomaniak/auth/utils/AccountUtils.kt | 1 + .../1.json | 17 +++++++++-- .../commonMain/kotlin/AuthenticatorFacade.kt | 2 ++ .../kotlin/DummyAuthenticatorFacade.kt | 4 +++ .../internal/AuthenticatorFacadeImpl.kt | 29 +++++++++++++++++-- .../kotlin/internal/db/AccountEntity.kt | 2 ++ .../kotlin/internal/db/AccountsDao.kt | 3 ++ 9 files changed, 74 insertions(+), 11 deletions(-) diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountlist/AccountListScreen.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountlist/AccountListScreen.kt index 2aabf829..1ff26aa6 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountlist/AccountListScreen.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountlist/AccountListScreen.kt @@ -64,6 +64,7 @@ import com.infomaniak.auth.ui.components.Avatar import com.infomaniak.auth.ui.components.StatusCard import com.infomaniak.auth.ui.components.StatusCardVariant import com.infomaniak.auth.ui.previewparameter.fakeAccountPairs +import com.infomaniak.auth.ui.screen.accountlist.AccountListViewModel.AccountListUiState import com.infomaniak.auth.ui.screen.accountlist.AccountSecurityLevel.Companion.toAccountSecurityLevel import com.infomaniak.auth.ui.theme.AppDimens.DefaultCornerRadius import com.infomaniak.auth.ui.theme.AuthenticatorTheme @@ -86,6 +87,7 @@ fun AccountListScreen( uiState = { state }, onAccountClicked = onAccountClicked, onChallengesRefreshRequested = viewModel::refreshChallenges, + onUserProfilesRefreshRequested = viewModel::refreshUserProfiles, ) } is AccountListUiState.Loading -> Unit @@ -97,6 +99,7 @@ fun AccountListScreen( uiState: () -> AccountListUiState.Success, onAccountClicked: (Account) -> Unit, onChallengesRefreshRequested: () -> Unit, + onUserProfilesRefreshRequested: () -> Unit, modifier: Modifier = Modifier ) { val state = uiState() @@ -122,6 +125,7 @@ fun AccountListScreen( onRefresh = { isRefreshing = true onChallengesRefreshRequested() + onUserProfilesRefreshRequested() }, ) { Column( @@ -247,6 +251,7 @@ private fun AccountListScreenPreview() { uiState = { AccountListUiState.Success(fakeAccountPairs) }, onAccountClicked = {}, onChallengesRefreshRequested = {}, + onUserProfilesRefreshRequested = {}, ) } } diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountlist/AccountListViewModel.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountlist/AccountListViewModel.kt index 642989c7..c88019f5 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountlist/AccountListViewModel.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountlist/AccountListViewModel.kt @@ -28,6 +28,7 @@ import com.infomaniak.core.twofactorauth.back.TwoFactorAuthManager import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -39,7 +40,7 @@ import javax.inject.Inject @HiltViewModel class AccountListViewModel @Inject constructor( - accountUtils: AccountUtils, + private val accountUtils: AccountUtils, private val authenticatorFacade: AuthenticatorFacade, private val twoFactorAuthManager: TwoFactorAuthManager ) : ViewModel() { @@ -70,10 +71,19 @@ class AccountListViewModel @Inject constructor( } } } -} -@Immutable -sealed interface AccountListUiState { - data object Loading : AccountListUiState - data class Success(val accountPairs: ImmutableList>) : AccountListUiState + fun refreshUserProfiles() { + viewModelScope.launch(Dispatchers.IO) { + val userIds = authenticatorFacade.accounts.first().map { it.id.toInt() }.toIntArray() + accountUtils.getUsersById(userIds).forEach { user -> + authenticatorFacade.refreshUserProfileFor(user.apiToken.accessToken, user.id.toLong()) + } + } + } + + @Immutable + sealed interface AccountListUiState { + data object Loading : AccountListUiState + data class Success(val accountPairs: ImmutableList>) : AccountListUiState + } } diff --git a/app/src/main/kotlin/com/infomaniak/auth/utils/AccountUtils.kt b/app/src/main/kotlin/com/infomaniak/auth/utils/AccountUtils.kt index fbbbd7f0..e302e4f6 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/utils/AccountUtils.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/utils/AccountUtils.kt @@ -33,4 +33,5 @@ class AccountUtils @Inject constructor( suspend fun isUserConnected(): Boolean = users.first().isNotEmpty() suspend fun getUserById(id: Int): User? = userDao.findById(id) + suspend fun getUsersById(userIds: IntArray): List = userDao.loadAllByIds(userIds) } diff --git a/multiplatform-lib/schemas/com.infomaniak.auth.lib.internal.db.AccountsDatabase/1.json b/multiplatform-lib/schemas/com.infomaniak.auth.lib.internal.db.AccountsDatabase/1.json index 7c7b64d9..118a84a8 100644 --- a/multiplatform-lib/schemas/com.infomaniak.auth.lib.internal.db.AccountsDatabase/1.json +++ b/multiplatform-lib/schemas/com.infomaniak.auth.lib.internal.db.AccountsDatabase/1.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "ab7e6c6aadc7811b61b0ffe94bc67ec1", + "identityHash": "9a233d24627b386b646a6f9418812e76", "entities": [ { "tableName": "AccountEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `fullName` TEXT NOT NULL, `initials` TEXT NOT NULL, `email` TEXT NOT NULL, `avatarUrl` TEXT, `status` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `fullName` TEXT NOT NULL, `initials` TEXT NOT NULL, `email` TEXT NOT NULL, `avatarUrl` TEXT, `status` INTEGER NOT NULL, `securityScore` INTEGER NOT NULL, `lastPasswordUpdate` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -42,6 +42,17 @@ "columnName": "status", "affinity": "INTEGER", "notNull": true + }, + { + "fieldPath": "securityScore", + "columnName": "securityScore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPasswordUpdate", + "columnName": "lastPasswordUpdate", + "affinity": "INTEGER" } ], "primaryKey": { @@ -54,7 +65,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ab7e6c6aadc7811b61b0ffe94bc67ec1')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9a233d24627b386b646a6f9418812e76')" ] } } \ No newline at end of file diff --git a/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt b/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt index e18b45a1..d25bb24f 100644 --- a/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt +++ b/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt @@ -62,6 +62,8 @@ abstract class AuthenticatorFacade internal constructor() { @Throws(Exception::class) abstract suspend fun refreshTokenFor(userId: Long) + abstract suspend fun refreshUserProfileFor(token: String, userId: Long) + companion object { fun create( diff --git a/multiplatform-lib/src/commonMain/kotlin/DummyAuthenticatorFacade.kt b/multiplatform-lib/src/commonMain/kotlin/DummyAuthenticatorFacade.kt index 589adf5b..80eeae11 100644 --- a/multiplatform-lib/src/commonMain/kotlin/DummyAuthenticatorFacade.kt +++ b/multiplatform-lib/src/commonMain/kotlin/DummyAuthenticatorFacade.kt @@ -119,4 +119,8 @@ class DummyAuthenticatorFacade internal constructor( override suspend fun refreshTokenFor(userId: Long) { TODO("Not yet implemented") } + + override suspend fun refreshUserProfileFor(token: String, userId: Long) { + TODO("Not yet implemented") + } } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt index 5e83f250..c950074e 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt @@ -41,6 +41,7 @@ import com.infomaniak.auth.lib.internal.utils.raceOf import com.infomaniak.auth.lib.internal.utils.sharedFlow import com.infomaniak.auth.lib.internal.utils.waitForComplete import com.infomaniak.auth.lib.internal.utils.withTimeoutOrNull +import com.infomaniak.auth.lib.models.migration.user.UserProfile import com.infomaniak.auth.lib.network.exceptions.ApiException import com.infomaniak.auth.lib.network.exceptions.NetworkException import com.infomaniak.auth.lib.network.interfaces.AuthenticatorBridge @@ -142,6 +143,10 @@ internal class AuthenticatorFacadeImpl( authenticatorBridge.persistTokenForAccount(userId, token.accessToken) } + override suspend fun refreshUserProfileFor(token: String, userId: Long) { + syncAccountWithUserProfile(token, userId) + } + private fun accountsFlow( entities: List, accountsToLogin: Map> @@ -268,7 +273,14 @@ internal class AuthenticatorFacadeImpl( userId = userId, ).firstOrElse { error("Key not found: ${it.details}") } authenticatorBridge.persistTokenForAccount(userId, token.accessToken) - dao.upsert(notRegisteredAccount.copy(status = AccountEntity.Status.LoggedIn)) + val profile = authenticatorManager.getUserProfile(token.accessToken) + dao.upsert( + notRegisteredAccount.copy( + securityScore = profile.preferences.security.score, + lastPasswordUpdate = profile.preferences.security.dateLastChangedPassword, + status = AccountEntity.Status.LoggedIn + ) + ) } } @@ -332,7 +344,7 @@ internal class AuthenticatorFacadeImpl( userId = userId, authentication = authentication, persistUser = { apiToken -> - val userProfile = authenticatorManager.getUserProfile(apiToken.accessToken) + val userProfile = syncAccountWithUserProfile(apiToken.accessToken, userId) userProfile.apiToken = apiToken authenticatorBridge.persistUserProfile(userProfile) }, @@ -345,6 +357,19 @@ internal class AuthenticatorFacadeImpl( return true } + private suspend fun syncAccountWithUserProfile(token: String, userId: Long): UserProfile { + val userProfile = authenticatorManager.getUserProfile(token) + dao.getAccount(userId)?.let { account -> + dao.upsert( + account.copy( + securityScore = userProfile.preferences.security.score, + lastPasswordUpdate = userProfile.preferences.security.dateLastChangedPassword + ) + ) + } + return userProfile + } + private suspend fun FlowCollector.tryMigratingWithReLogin(accountToMigrate: AccountEntity) { var status = ReLogin( legacyAccount = accountToMigrate.toAccount(null), diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt index 9d417017..1591a3c0 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt @@ -29,6 +29,8 @@ internal data class AccountEntity( val email: String, val avatarUrl: String? = null, val status: Status, + val securityScore: Int = 0, + val lastPasswordUpdate: Long? = null ) { val isLoggedIn: Boolean get() = status == Status.LoggedIn diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt index 1f7cbfce..b30ea05a 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountsDao.kt @@ -32,6 +32,9 @@ internal interface AccountsDao { @Query("SELECT * FROM AccountEntity WHERE id = :id") fun getAccountAsFlow(id: Long): Flow + @Query("SELECT * FROM AccountEntity WHERE id = :id") + suspend fun getAccount(id: Long): AccountEntity? + @Upsert suspend fun upsert(account: AccountEntity) From 7cb8a06b2419748b7680f3474af1bb8bbbdcebe4 Mon Sep 17 00:00:00 2001 From: Vincent Te Date: Fri, 1 May 2026 13:01:38 +0200 Subject: [PATCH 2/4] feat: Expose accountsWithPasswordUpdate in MainView to display a dialog when an Account's password has been updated --- .../accountdetails/ActionRequiredCard.kt | 4 +- .../auth/ui/screen/main/MainScreen.kt | 10 ++++- .../auth/ui/screen/main/MainViewModel.kt | 2 + .../src/commonMain/kotlin/Account.kt | 2 + .../commonMain/kotlin/AuthenticatorFacade.kt | 1 + .../kotlin/DummyAuthenticatorFacade.kt | 2 + .../internal/AuthenticatorFacadeImpl.kt | 45 ++++++++++++++++++- .../kotlin/internal/db/AccountEntity.kt | 4 +- .../Models \342\206\224 Entities.kt" | 3 +- .../internal/managers/AccountRestorer.kt | 1 + 10 files changed, 68 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountdetails/ActionRequiredCard.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountdetails/ActionRequiredCard.kt index 048be932..3e6d9e26 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountdetails/ActionRequiredCard.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountdetails/ActionRequiredCard.kt @@ -134,7 +134,9 @@ fun ActionRequiredCard( } } } - is Account.Status.LoggedIn, is Account.Status.NotConnected.AttemptingToConnect -> Unit + is Account.Status.LoggedIn, + is Account.Status.NotConnected.AttemptingToConnect, + is Account.Status.PasswordChanged -> Unit } } diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainScreen.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainScreen.kt index c8f51a66..0bd7e087 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainScreen.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainScreen.kt @@ -40,6 +40,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.rememberPermissionState +import com.infomaniak.auth.lib.Account import com.infomaniak.auth.lib.AppStatus import com.infomaniak.auth.ui.navigation.NavDestination import com.infomaniak.auth.ui.navigation.baseEntryProvider @@ -85,10 +86,17 @@ fun MainScreen( } } + var showPasswordDialogFor: Account? by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + viewModel.accountsWithPasswordUpdate.collect { accounts -> + accounts.firstOrNull()?.let { showPasswordDialogFor = it } + } + } + MainScreen(backStack, entryDecorators) } - @OptIn(ExperimentalPermissionsApi::class) private fun handleAppStatus( appStatus: AppStatus, diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainViewModel.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainViewModel.kt index d490a806..195a8ee8 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainViewModel.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainViewModel.kt @@ -39,6 +39,8 @@ class MainViewModel @Inject constructor( ) : ViewModel() { val appStatus = authenticatorFacade.appStatus + val accountsWithPasswordUpdate = authenticatorFacade.accountsWithUpdatedPassword + val isAppLocked = appSettingsRepository.getSettings().mapNotNull { it?.isAppLockEnabled } val hasTriggeredNotificationPermission: StateFlow = flow { emitAll(PermissionPreferences().hasTriggeredNotificationPermissionFlow) diff --git a/multiplatform-lib/src/commonMain/kotlin/Account.kt b/multiplatform-lib/src/commonMain/kotlin/Account.kt index db4a5d00..542ffc0e 100644 --- a/multiplatform-lib/src/commonMain/kotlin/Account.kt +++ b/multiplatform-lib/src/commonMain/kotlin/Account.kt @@ -55,5 +55,7 @@ data class Account( data class LoginFailed(val issue: Issue) : NotConnected } + + data class PasswordChanged(val hasBeenHandled: (Unit) -> Unit) : Status } } diff --git a/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt b/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt index d25bb24f..f556b0e7 100644 --- a/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt +++ b/multiplatform-lib/src/commonMain/kotlin/AuthenticatorFacade.kt @@ -38,6 +38,7 @@ import kotlin.time.Duration.Companion.seconds abstract class AuthenticatorFacade internal constructor() { abstract val accounts: Flow> + abstract val accountsWithUpdatedPassword: Flow> abstract val appStatus: SharedFlow diff --git a/multiplatform-lib/src/commonMain/kotlin/DummyAuthenticatorFacade.kt b/multiplatform-lib/src/commonMain/kotlin/DummyAuthenticatorFacade.kt index 80eeae11..1988c1d0 100644 --- a/multiplatform-lib/src/commonMain/kotlin/DummyAuthenticatorFacade.kt +++ b/multiplatform-lib/src/commonMain/kotlin/DummyAuthenticatorFacade.kt @@ -44,6 +44,8 @@ class DummyAuthenticatorFacade internal constructor( resetAfter: Duration, ) : AuthenticatorFacade() { override val accounts: Flow> + override val accountsWithUpdatedPassword: Flow> + get() = TODO("Not yet implemented") private var _accounts: List by MutableStateFlow>(emptyList()).also { accounts = accountsRepository.getAccounts().map { diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt index c950074e..e3fd571a 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt @@ -27,6 +27,7 @@ import com.infomaniak.auth.lib.CredentialsForMigration import com.infomaniak.auth.lib.Issue import com.infomaniak.auth.lib.Issue.Retriable.Cause import com.infomaniak.auth.lib.internal.db.AccountEntity +import com.infomaniak.auth.lib.internal.db.AccountEntity.Status import com.infomaniak.auth.lib.internal.db.AccountsDatabase import com.infomaniak.auth.lib.internal.extensions.cancellable import com.infomaniak.auth.lib.internal.extensions.firstOrElse @@ -73,6 +74,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch import kotlinx.io.IOException import kotlin.time.Duration.Companion.seconds @@ -113,7 +115,9 @@ internal class AuthenticatorFacadeImpl( override val accounts: Flow> = channelFlow { accountEntities.collectLatest { entities -> - val idsOfAccountsToLogIn = entities.mapNotNull { entity -> entity.id.takeUnless { entity.isLoggedIn } }.toSet() + val idsOfAccountsToLogIn = + entities.mapNotNull { entity -> entity.id.takeUnless { entity.isLoggedIn } } + .toSet() accountsToLogin.useElements(idsOfAccountsToLogIn) { map -> accountsFlow(entities, map).collectLatest { send(it) } awaitCancellation() // Stay in the useElements scope until a new list of accounts is received. @@ -121,6 +125,40 @@ internal class AuthenticatorFacadeImpl( } }.flowOn(Dispatchers.Default).distinctUntilChanged().shareIn(coroutineScope, SharingStarted.Eagerly, replay = 1) + override val accountsWithUpdatedPassword: Flow> = channelFlow { + accountEntities.collectLatest { entities -> + val idsOfAccountsWithUpdatedPassword = + entities.mapNotNull { entity -> entity.id.takeUnless { entity.status != AccountEntity.Status.PasswordChanged } } + .toSet() + val accountsWithUpdatedPassword = accounts.first().filter { it.id in idsOfAccountsWithUpdatedPassword } + + val passwordUpdatedHandledList = mutableListOf>>() + val passwordUpdatedAccounts = accountsWithUpdatedPassword.map { + val passwordUpdatedHandled = CompletableDeferred() + passwordUpdatedHandledList.add(it.id to passwordUpdatedHandled) + val modified = + it.copy(status = Account.Status.PasswordChanged(hasBeenHandled = passwordUpdatedHandled::complete)) + modified + } + + send(passwordUpdatedAccounts) + + launch { + kotlinx.coroutines.selects.select { + passwordUpdatedHandledList.forEach { (userId, signal) -> + signal.onAwait { + dao.getAccount(userId)?.let { account -> + if (account.status == AccountEntity.Status.PasswordChanged) { + dao.upsert(account.copy(status = AccountEntity.Status.LoggedIn)) + } + } + } + } + } + } + } + }.flowOn(Dispatchers.Default).distinctUntilChanged().shareIn(coroutineScope, SharingStarted.Eagerly, replay = 1) + override val appStatus: SharedFlow = appStatusFlow() .shareIn(coroutineScope, SharingStarted.Eagerly, replay = 1) @@ -236,7 +274,7 @@ internal class AuthenticatorFacadeImpl( AccountEntity.Status.RestoringFromBackup, AccountEntity.Status.DeletingOldKeyAfterRestoration -> { restoreFromBackupAttempts(account = entity) } - AccountEntity.Status.LoggedIn, null -> Unit // Should not happen in practice. + AccountEntity.Status.LoggedIn, Status.PasswordChanged, null -> Unit // Should not happen in practice. } } @@ -360,8 +398,11 @@ internal class AuthenticatorFacadeImpl( private suspend fun syncAccountWithUserProfile(token: String, userId: Long): UserProfile { val userProfile = authenticatorManager.getUserProfile(token) dao.getAccount(userId)?.let { account -> + val passwordHasBeenUpdated = + account.lastPasswordUpdate != null && userProfile.preferences.security.dateLastChangedPassword > account.lastPasswordUpdate dao.upsert( account.copy( + status = if (passwordHasBeenUpdated) Status.PasswordChanged else account.status, securityScore = userProfile.preferences.security.score, lastPasswordUpdate = userProfile.preferences.security.dateLastChangedPassword ) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt index 1591a3c0..5b00f859 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/db/AccountEntity.kt @@ -32,7 +32,7 @@ internal data class AccountEntity( val securityScore: Int = 0, val lastPasswordUpdate: Long? = null ) { - val isLoggedIn: Boolean get() = status == Status.LoggedIn + val isLoggedIn: Boolean get() = status == Status.LoggedIn || status == Status.PasswordChanged enum class Status { @@ -60,5 +60,7 @@ internal data class AccountEntity( RestoringFromBackup, DeletingOldKeyAfterRestoration, + + PasswordChanged, } } diff --git "a/multiplatform-lib/src/commonMain/kotlin/internal/extensions/Models \342\206\224 Entities.kt" "b/multiplatform-lib/src/commonMain/kotlin/internal/extensions/Models \342\206\224 Entities.kt" index d2a05c5c..ed1555dd 100644 --- "a/multiplatform-lib/src/commonMain/kotlin/internal/extensions/Models \342\206\224 Entities.kt" +++ "b/multiplatform-lib/src/commonMain/kotlin/internal/extensions/Models \342\206\224 Entities.kt" @@ -22,7 +22,7 @@ import com.infomaniak.auth.lib.internal.db.AccountEntity import com.infomaniak.auth.lib.internal.db.AccountEntity.Status import com.infomaniak.auth.lib.internal.models.LegacyUser -internal fun AccountEntity.toAccount(action: Account.Status.NotConnected?): Account { +internal fun AccountEntity.toAccount(action: Account.Status?): Account { return Account( id = id, fullName = fullName, @@ -31,6 +31,7 @@ internal fun AccountEntity.toAccount(action: Account.Status.NotConnected?): Acco avatarUrl = avatarUrl, status = when (status) { AccountEntity.Status.LoggedIn -> Account.Status.LoggedIn + AccountEntity.Status.PasswordChanged -> action ?: Account.Status.LoggedIn else -> action ?: Account.Status.NotConnected.AttemptingToConnect } ) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AccountRestorer.kt b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AccountRestorer.kt index a1b890a3..d326511e 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/managers/AccountRestorer.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/managers/AccountRestorer.kt @@ -80,6 +80,7 @@ internal class AccountRestorer( private fun AccountEntity.hasNewKeyAlreadyBeenRegistered() = when (status) { AccountEntity.Status.RestoringFromBackup -> false AccountEntity.Status.DeletingOldKeyAfterRestoration -> true + AccountEntity.Status.PasswordChanged, AccountEntity.Status.ToBeMigrated, AccountEntity.Status.PasskeyRegistrationPending, AccountEntity.Status.FirstPasskeyAuthenticationPending, From 473187d7ca4df9c2177f9f7ebe6e34f5d47e785d Mon Sep 17 00:00:00 2001 From: Vincent Te Date: Fri, 1 May 2026 13:22:27 +0200 Subject: [PATCH 3/4] chore: Fix build --- .../com/infomaniak/auth/ui/screen/main/MainScreen.kt | 7 ++++++- .../commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt | 4 ++-- .../models/migration/user/preferences/Preferences.kt | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainScreen.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainScreen.kt index 0bd7e087..003245f5 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainScreen.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainScreen.kt @@ -24,7 +24,9 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavBackStack @@ -107,7 +109,10 @@ private fun handleAppStatus( val targetDestination = when (appStatus) { is AppStatus.LoginRequired.NotMigrating -> NavDestination.Onboarding.Start is AppStatus.LoginRequired.MigratingFromLegacyKAuth -> NavDestination.Onboarding.Migration - is AppStatus.LoginRequired.MustReLogin -> NavDestination.LoginInApp(legacyAccountId = appStatus.accountId, isOnboarding = true) + is AppStatus.LoginRequired.MustReLogin -> NavDestination.LoginInApp( + legacyAccountId = appStatus.accountId, + isOnboarding = true + ) is AppStatus.LoggingIn -> NavDestination.SecuringAccount is AppStatus.EverythingReady -> NavDestination.Onboarding.Complete is AppStatus.SetupComplete -> { diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt index e3fd571a..15a52598 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt @@ -42,7 +42,7 @@ import com.infomaniak.auth.lib.internal.utils.raceOf import com.infomaniak.auth.lib.internal.utils.sharedFlow import com.infomaniak.auth.lib.internal.utils.waitForComplete import com.infomaniak.auth.lib.internal.utils.withTimeoutOrNull -import com.infomaniak.auth.lib.models.migration.user.UserProfile +import com.infomaniak.auth.lib.models.migration.user.SharedUserProfile import com.infomaniak.auth.lib.network.exceptions.ApiException import com.infomaniak.auth.lib.network.exceptions.NetworkException import com.infomaniak.auth.lib.network.interfaces.AuthenticatorBridge @@ -395,7 +395,7 @@ internal class AuthenticatorFacadeImpl( return true } - private suspend fun syncAccountWithUserProfile(token: String, userId: Long): UserProfile { + private suspend fun syncAccountWithUserProfile(token: String, userId: Long): SharedUserProfile { val userProfile = authenticatorManager.getUserProfile(token) dao.getAccount(userId)?.let { account -> val passwordHasBeenUpdated = diff --git a/multiplatform-lib/src/commonMain/kotlin/models/migration/user/preferences/Preferences.kt b/multiplatform-lib/src/commonMain/kotlin/models/migration/user/preferences/Preferences.kt index 1f91fd9e..91c35ce6 100644 --- a/multiplatform-lib/src/commonMain/kotlin/models/migration/user/preferences/Preferences.kt +++ b/multiplatform-lib/src/commonMain/kotlin/models/migration/user/preferences/Preferences.kt @@ -23,7 +23,7 @@ import kotlinx.serialization.Serializable @Serializable data class Preferences( - var security: SharedSecurity? = null, + var security: SharedSecurity, @SerialName("account") var organizationPreference: SharedOrganizationPreference, var language: SharedLanguage, From cfa324ff99ad99bb3f17fc703ad7b46fc3f1c5e5 Mon Sep 17 00:00:00 2001 From: Vincent Te Date: Fri, 1 May 2026 13:36:49 +0200 Subject: [PATCH 4/4] chore: Delete unnecessary safe call --- .../main/kotlin/com/infomaniak/auth/utils/MigrationUtils.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/infomaniak/auth/utils/MigrationUtils.kt b/app/src/main/kotlin/com/infomaniak/auth/utils/MigrationUtils.kt index 16fe90ea..11d23007 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/utils/MigrationUtils.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/utils/MigrationUtils.kt @@ -18,10 +18,10 @@ package com.infomaniak.auth.utils import com.infomaniak.auth.lib.models.migration.user.SharedUserProfile +import com.infomaniak.auth.lib.models.migration.user.preferences.Preferences import com.infomaniak.auth.lib.models.migration.user.preferences.SharedCountry import com.infomaniak.auth.lib.models.migration.user.preferences.SharedLanguage import com.infomaniak.auth.lib.models.migration.user.preferences.SharedOrganizationPreference -import com.infomaniak.auth.lib.models.migration.user.preferences.Preferences import com.infomaniak.auth.lib.models.migration.user.preferences.SharedTimeZone import com.infomaniak.auth.lib.models.migration.user.preferences.security.SharedAuthDevices import com.infomaniak.auth.lib.models.migration.user.preferences.security.SharedSecurity @@ -60,7 +60,7 @@ fun SharedUserProfile.toUser(): User { } private fun Preferences.toCorePreferences() = CorePreferences( - security = security?.toCoreSecurity(), + security = security.toCoreSecurity(), organizationPreference = organizationPreference.toCoreOrganizationPreference(), language = language.toCoreLanguage(), country = country.toCoreCountry(),