mirror of
https://github.com/ProtonMail/protoncore_android.git
synced 2026-05-15 09:50:41 +00:00
feat!(auth): Added Global SSO Change Password support.
MIGRATIONS: - UserDatabase.MIGRATION_6 - AccountDatabase.MIGRATION_9
This commit is contained in:
+3508
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -202,7 +202,7 @@ abstract class AccountManagerDatabase :
|
||||
|
||||
companion object {
|
||||
const val name = "db-account-manager"
|
||||
const val version = 56
|
||||
const val version = 57
|
||||
|
||||
val migrations = listOf(
|
||||
AccountManagerDatabaseMigrations.MIGRATION_1_2,
|
||||
@@ -259,7 +259,8 @@ abstract class AccountManagerDatabase :
|
||||
AccountManagerDatabaseMigrations.MIGRATION_52_53,
|
||||
AccountManagerDatabaseMigrations.MIGRATION_53_54,
|
||||
AccountManagerDatabaseMigrations.MIGRATION_54_55,
|
||||
AccountManagerDatabaseMigrations.MIGRATION_55_56
|
||||
AccountManagerDatabaseMigrations.MIGRATION_55_56,
|
||||
AccountManagerDatabaseMigrations.MIGRATION_56_57,
|
||||
)
|
||||
|
||||
fun databaseBuilder(context: Context): Builder<AccountManagerDatabase> =
|
||||
|
||||
+7
@@ -392,4 +392,11 @@ object AccountManagerDatabaseMigrations {
|
||||
MailSettingsDatabase.MIGRATION_1.migrate(db)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_56_57 = object : Migration(56, 57) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
UserDatabase.MIGRATION_6.migrate(db)
|
||||
AccountDatabase.MIGRATION_9.migrate(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -81,6 +81,7 @@ class AccountSettingsViewModelTest : CoroutinesTest by CoroutinesTest() {
|
||||
delinquent = null,
|
||||
recovery = null,
|
||||
keys = emptyList(),
|
||||
flags = emptyMap(),
|
||||
type = Type.Proton
|
||||
)
|
||||
|
||||
|
||||
+2
-1
@@ -61,7 +61,8 @@ class ObserveUserRecoveryStateTest {
|
||||
sessionId = SessionId("test-session-id"),
|
||||
reason = UserRecovery.Reason.Authentication
|
||||
),
|
||||
keys = emptyList()
|
||||
keys = emptyList(),
|
||||
flags = emptyMap(),
|
||||
)
|
||||
private val testUserNullRecovery = testUser.copy(recovery = null)
|
||||
|
||||
|
||||
@@ -175,5 +175,17 @@ interface AccountDatabase : Database {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* - Added [User.flags], insert migration to populate the value from backend (see AccountMigrator).
|
||||
*/
|
||||
val MIGRATION_9 = object : DatabaseMigration {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// If there are no migrations the value is NULL.
|
||||
database.execSQL("UPDATE AccountMetadataEntity SET migrations = IFNULL(migrations || ';RefreshUser', 'RefreshUser')")
|
||||
database.execSQL("UPDATE AccountEntity SET state = 'MigrationNeeded' WHERE state = 'Ready'")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ data class SRPAuthenticationResponse(
|
||||
@SerialName("Code")
|
||||
val code: Int,
|
||||
@SerialName("ServerProof")
|
||||
val serverProof: ServerProof,
|
||||
val serverProof: ServerProof? = null,
|
||||
)
|
||||
|
||||
fun SRPAuthenticationResponse.isSuccess(): Boolean = code == ResponseCodes.OK
|
||||
|
||||
@@ -26,4 +26,5 @@ object LogTag {
|
||||
const val ACTIVATE_DEVICE = "core.auth.domain.perform.backuppass.activate"
|
||||
const val UNPRIVATIZE_USER = "core.auth.domain.perform.backuppass.unprivatize"
|
||||
const val SETUP_KEYS = "core.auth.domain.perform.backuppass.setup.keys"
|
||||
const val CHANGE_BACKUP_PASSWORD = "core.auth.domain.perform.backuppass.change"
|
||||
}
|
||||
|
||||
+1
@@ -78,6 +78,7 @@ class PostLoginLessAccountSetup @Inject constructor(
|
||||
delinquent = null,
|
||||
recovery = null,
|
||||
keys = emptyList(),
|
||||
flags = emptyMap(),
|
||||
maxBaseSpace = 0,
|
||||
maxDriveSpace = 0,
|
||||
usedBaseSpace = 0,
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ class ValidateServerProof @Inject constructor() {
|
||||
* Throws an [InvalidServerAuthenticationException] with the result of calling [lazyMessage]
|
||||
* if the [ServerProof] isn't the one expected.
|
||||
*/
|
||||
operator fun invoke(serverProof: ServerProof, expectedProof: String, lazyMessage: () -> Any) {
|
||||
operator fun invoke(serverProof: ServerProof?, expectedProof: String?, lazyMessage: () -> Any) {
|
||||
if (serverProof != expectedProof) {
|
||||
val message = "Server returned invalid srp proof, ${lazyMessage.invoke()}"
|
||||
val exception = InvalidServerAuthenticationException(message)
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (c) 2024 Proton AG
|
||||
* This file is part of Proton AG and ProtonCore.
|
||||
*
|
||||
* ProtonCore is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* ProtonCore is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.core.auth.domain.usecase.sso
|
||||
|
||||
import me.proton.core.account.domain.repository.AccountRepository
|
||||
import me.proton.core.auth.domain.repository.AuthRepository
|
||||
import me.proton.core.auth.domain.repository.DeviceSecretRepository
|
||||
import me.proton.core.crypto.common.context.CryptoContext
|
||||
import me.proton.core.crypto.common.keystore.EncryptedString
|
||||
import me.proton.core.crypto.common.keystore.decrypt
|
||||
import me.proton.core.crypto.common.keystore.use
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.user.domain.UserManager
|
||||
import me.proton.core.user.domain.extension.nameNotNull
|
||||
import me.proton.core.user.domain.repository.PassphraseRepository
|
||||
import me.proton.core.user.domain.repository.UserRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class ChangeBackupPassword @Inject constructor(
|
||||
context: CryptoContext,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val authRepository: AuthRepository,
|
||||
private val userManager: UserManager,
|
||||
private val userRepository: UserRepository,
|
||||
private val passphraseRepository: PassphraseRepository,
|
||||
private val deviceSecretRepository: DeviceSecretRepository,
|
||||
private val getEncryptedSecret: GetEncryptedSecret,
|
||||
) {
|
||||
private val keyStore = context.keyStoreCrypto
|
||||
private val srp = context.srpCrypto
|
||||
|
||||
suspend operator fun invoke(
|
||||
userId: UserId,
|
||||
newBackupPassword: EncryptedString,
|
||||
): Boolean {
|
||||
val user = userRepository.getUser(userId)
|
||||
val username = user.nameNotNull()
|
||||
val account = accountRepository.getAccountOrNull(userId)
|
||||
val sessionId = requireNotNull(account?.sessionId)
|
||||
val modulus = authRepository.randomModulus(sessionId)
|
||||
val currentPassphrase = requireNotNull(passphraseRepository.getPassphrase(userId))
|
||||
val deviceSecret = requireNotNull(deviceSecretRepository.getByUserId(userId)?.secret)
|
||||
currentPassphrase.decrypt(keyStore).use { decryptedCurrentPassphrase ->
|
||||
newBackupPassword.decrypt(keyStore).toByteArray().use { decryptedBackupPassword ->
|
||||
val auth = srp.calculatePasswordVerifier(
|
||||
username = username,
|
||||
password = decryptedBackupPassword.array,
|
||||
modulusId = modulus.modulusId,
|
||||
modulus = modulus.modulus
|
||||
)
|
||||
return userManager.changePassword(
|
||||
userId = userId,
|
||||
newPassword = newBackupPassword,
|
||||
secondFactorProof = null,
|
||||
proofs = null,
|
||||
srpSession = null,
|
||||
auth = auth,
|
||||
encryptedSecret = getEncryptedSecret.invoke(
|
||||
passphrase = decryptedCurrentPassphrase,
|
||||
deviceSecret = deviceSecret
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -42,8 +42,8 @@ class CheckOtherDevices @Inject constructor(
|
||||
val devices = authDeviceRepository.getByUserId(userId)
|
||||
val activeDevices = devices.filter { it.state == AuthDeviceState.Active }
|
||||
return when {
|
||||
hasTemporaryPassword -> Result.AdminHelpRequired
|
||||
localDevice.isPendingAdmin() -> Result.AdminHelpRequested
|
||||
hasTemporaryPassword -> Result.AdminHelpRequired
|
||||
activeDevices.isNotEmpty() -> Result.OtherDevicesAvailable(devices)
|
||||
else -> Result.BackupPassword
|
||||
}
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ class GetEncryptedSecret @Inject constructor(
|
||||
operator fun invoke(
|
||||
passphrase: PlainByteArray,
|
||||
deviceSecret: DeviceSecretString
|
||||
): Based64EncodedAeadEncryptedSecret = pgpCrypto.getBase64Encoded(
|
||||
): Based64EncodedAeadEncryptedSecret = pgpCrypto.getBase64EncodedNoWrap(
|
||||
pgpCrypto.getBase64Decoded(deviceSecret.decrypt(keyStoreCrypto)).use { key ->
|
||||
passphrase.encrypt(
|
||||
crypto = aeadCrypto,
|
||||
|
||||
+2
-1
@@ -70,7 +70,8 @@ internal class AccountAvailabilityTest {
|
||||
delinquent = null,
|
||||
recovery = null,
|
||||
keys = emptyList(),
|
||||
type = Type.Proton
|
||||
type = Type.Proton,
|
||||
flags = emptyMap(),
|
||||
)
|
||||
|
||||
@BeforeTest
|
||||
|
||||
+3
-1
@@ -66,7 +66,9 @@ class PostLoginSsoAccountSetupTest {
|
||||
fun setUp() {
|
||||
accountWorkflowHandler = mockk()
|
||||
|
||||
user = mockk()
|
||||
user = mockk {
|
||||
every { flags } returns emptyMap()
|
||||
}
|
||||
sessionId = mockk()
|
||||
userCheck = mockk {
|
||||
coEvery { this@mockk.invoke(any()) } returns PostLoginAccountSetup.UserCheckResult.Success
|
||||
|
||||
+12
-2
@@ -51,6 +51,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import me.proton.core.auth.presentation.compose.DeviceSecretViewState.ChangePassword
|
||||
import me.proton.core.auth.presentation.compose.DeviceSecretViewState.Close
|
||||
import me.proton.core.auth.presentation.compose.DeviceSecretViewState.DeviceRejected
|
||||
import me.proton.core.auth.presentation.compose.DeviceSecretViewState.Error
|
||||
@@ -58,6 +59,7 @@ import me.proton.core.auth.presentation.compose.DeviceSecretViewState.FirstLogin
|
||||
import me.proton.core.auth.presentation.compose.DeviceSecretViewState.InvalidSecret
|
||||
import me.proton.core.auth.presentation.compose.DeviceSecretViewState.Loading
|
||||
import me.proton.core.auth.presentation.compose.DeviceSecretViewState.Success
|
||||
import me.proton.core.auth.presentation.compose.sso.BackupPasswordChangeScreen
|
||||
import me.proton.core.auth.presentation.compose.sso.BackupPasswordInputScreen
|
||||
import me.proton.core.auth.presentation.compose.sso.BackupPasswordSetupScreen
|
||||
import me.proton.core.auth.presentation.compose.sso.RequestAccessDeniedScreen
|
||||
@@ -125,12 +127,14 @@ public fun DeviceSecretScreen(
|
||||
is Close -> onClose()
|
||||
is Success -> onSuccess(state.userId)
|
||||
is Loading -> DeviceSecretScaffold(
|
||||
modifier = modifier,
|
||||
onCloseClicked = onCloseClicked,
|
||||
onRetryClicked = onReloadState,
|
||||
isLoading = true,
|
||||
email = state.email
|
||||
)
|
||||
is Error -> DeviceSecretScaffold(
|
||||
modifier = modifier,
|
||||
onCloseClicked = onCloseClicked,
|
||||
onRetryClicked = onReloadState,
|
||||
email = state.email,
|
||||
@@ -169,6 +173,7 @@ public fun DeviceSecretScreen(
|
||||
)
|
||||
|
||||
is InvalidSecret.NoDevice.RequireAdmin -> RequestAdminHelpScreen(
|
||||
modifier = modifier,
|
||||
onBackClicked = onCloseClicked,
|
||||
onErrorMessage = onErrorMessage,
|
||||
onSuccess = onReloadState,
|
||||
@@ -180,8 +185,13 @@ public fun DeviceSecretScreen(
|
||||
onBackToSignInClicked = onCloseClicked
|
||||
)
|
||||
|
||||
// TODO: Replace with BackupPasswordChangeScreen()
|
||||
is DeviceSecretViewState.ChangePassword -> onClose()
|
||||
is ChangePassword -> BackupPasswordChangeScreen(
|
||||
modifier = modifier,
|
||||
onCloseClicked = onCloseClicked,
|
||||
onCloseMessage = onCloseMessage,
|
||||
onErrorMessage = onErrorMessage,
|
||||
onSuccess = onReloadState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2024 Proton AG
|
||||
* This file is part of Proton AG and ProtonCore.
|
||||
*
|
||||
* ProtonCore is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* ProtonCore is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.core.auth.presentation.compose.sso
|
||||
|
||||
public sealed interface BackupPasswordChangeOperation
|
||||
|
||||
public sealed interface BackupPasswordChangeAction : BackupPasswordChangeOperation {
|
||||
public data class ChangePassword(
|
||||
val backupPassword: String,
|
||||
val repeatBackupPassword: String,
|
||||
val unused: Long = System.currentTimeMillis()
|
||||
) : BackupPasswordChangeAction
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* Copyright (c) 2024 Proton AG
|
||||
* This file is part of Proton AG and ProtonCore.
|
||||
*
|
||||
* ProtonCore is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* ProtonCore is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.core.auth.presentation.compose.sso
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.Scaffold
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Devices
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import me.proton.core.auth.presentation.compose.R
|
||||
import me.proton.core.compose.component.ProtonPasswordOutlinedTextFieldWithError
|
||||
import me.proton.core.compose.component.ProtonSolidButton
|
||||
import me.proton.core.compose.component.appbar.ProtonTopAppBar
|
||||
import me.proton.core.compose.theme.LocalColors
|
||||
import me.proton.core.compose.theme.ProtonDimens
|
||||
import me.proton.core.compose.theme.ProtonTheme
|
||||
import me.proton.core.compose.theme.ProtonTypography
|
||||
|
||||
@Composable
|
||||
public fun BackupPasswordChangeScreen(
|
||||
onCloseClicked: () -> Unit,
|
||||
onCloseMessage: (String?) -> Unit,
|
||||
onErrorMessage: (String?) -> Unit,
|
||||
onSuccess: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: BackupPasswordChangeViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
BackupPasswordChangeScreen(
|
||||
modifier = modifier,
|
||||
onCloseClicked = onCloseClicked,
|
||||
onContinueClicked = { viewModel.submit(it) },
|
||||
onCloseMessage = onCloseMessage,
|
||||
onErrorMessage = onErrorMessage,
|
||||
onSuccess = onSuccess,
|
||||
state = state
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
public fun BackupPasswordChangeScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onCloseClicked: () -> Unit = {},
|
||||
onContinueClicked: (BackupPasswordChangeAction.ChangePassword) -> Unit = {},
|
||||
onCloseMessage: (String?) -> Unit = {},
|
||||
onErrorMessage: (String?) -> Unit = {},
|
||||
onSuccess: () -> Unit = {},
|
||||
state: BackupPasswordChangeState,
|
||||
) {
|
||||
LaunchedEffect(state) {
|
||||
when (state) {
|
||||
is BackupPasswordChangeState.Close -> onCloseMessage(state.message)
|
||||
is BackupPasswordChangeState.Error -> onErrorMessage(state.message)
|
||||
is BackupPasswordChangeState.Success -> onSuccess()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
BackupPasswordChangeScaffold(
|
||||
modifier = modifier,
|
||||
onCloseClicked = onCloseClicked,
|
||||
onContinueClicked = onContinueClicked,
|
||||
isPasswordTooShort = state.isPasswordTooShort(),
|
||||
arePasswordsNotMatching = state.arePasswordsNotMatching(),
|
||||
isLoading = state is BackupPasswordChangeState.Loading
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
public fun BackupPasswordChangeScaffold(
|
||||
modifier: Modifier = Modifier,
|
||||
onCloseClicked: () -> Unit = {},
|
||||
onContinueClicked: (BackupPasswordChangeAction.ChangePassword) -> Unit = {},
|
||||
isPasswordTooShort: Boolean = false,
|
||||
arePasswordsNotMatching: Boolean = false,
|
||||
isLoading: Boolean = false,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
ProtonTopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onCloseClicked) {
|
||||
Icon(
|
||||
painterResource(id = R.drawable.ic_proton_close),
|
||||
contentDescription = stringResource(id = R.string.presentation_close)
|
||||
)
|
||||
}
|
||||
},
|
||||
backgroundColor = LocalColors.current.backgroundNorm
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(ProtonDimens.DefaultSpacing),
|
||||
) {
|
||||
|
||||
val errorTooShort = stringResource(R.string.backup_password_setup_password_too_short)
|
||||
val errorNotMatch = stringResource(R.string.backup_password_setup_password_not_matching)
|
||||
|
||||
BackupPasswordChangeForm(
|
||||
backupPasswordError = errorTooShort.takeIf { isPasswordTooShort },
|
||||
backupPasswordRepeatedError = errorNotMatch.takeIf { arePasswordsNotMatching },
|
||||
onContinueClicked = onContinueClicked,
|
||||
isLoading = isLoading,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackupPasswordChangeForm(
|
||||
backupPasswordError: String?,
|
||||
backupPasswordRepeatedError: String?,
|
||||
isLoading: Boolean,
|
||||
onContinueClicked: (BackupPasswordChangeAction.ChangePassword) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var backupPassword by rememberSaveable { mutableStateOf("") }
|
||||
var repeatBackupPassword by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.backup_password_change_title),
|
||||
style = ProtonTypography.Default.headline
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(top = ProtonDimens.MediumSpacing),
|
||||
text = stringResource(id = R.string.backup_password_change_description),
|
||||
style = ProtonTypography.Default.body2Regular
|
||||
)
|
||||
ProtonPasswordOutlinedTextFieldWithError(
|
||||
text = backupPassword,
|
||||
onValueChanged = { backupPassword = it },
|
||||
enabled = !isLoading,
|
||||
singleLine = true,
|
||||
label = { Text(text = stringResource(id = R.string.backup_password_setup_password_label)) },
|
||||
errorText = backupPasswordError,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
modifier = Modifier.padding(top = ProtonDimens.MediumSpacing)
|
||||
)
|
||||
ProtonPasswordOutlinedTextFieldWithError(
|
||||
text = repeatBackupPassword,
|
||||
onValueChanged = { repeatBackupPassword = it },
|
||||
enabled = !isLoading,
|
||||
singleLine = true,
|
||||
label = { Text(text = stringResource(id = R.string.backup_password_setup_repeat_password_label)) },
|
||||
errorText = backupPasswordRepeatedError,
|
||||
modifier = Modifier.padding(top = ProtonDimens.SmallSpacing)
|
||||
)
|
||||
ProtonSolidButton(
|
||||
contained = false,
|
||||
loading = isLoading,
|
||||
modifier = Modifier
|
||||
.padding(top = ProtonDimens.MediumSpacing)
|
||||
.height(ProtonDimens.DefaultButtonMinHeight),
|
||||
onClick = { onContinueClicked(BackupPasswordChangeAction.ChangePassword(backupPassword, repeatBackupPassword)) }
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.backup_password_setup_continue_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(device = Devices.TABLET)
|
||||
@Composable
|
||||
private fun BackupPasswordChangeScreenPreview() {
|
||||
ProtonTheme {
|
||||
BackupPasswordChangeScreen(state = BackupPasswordChangeState.Idle)
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2024 Proton AG
|
||||
* This file is part of Proton AG and ProtonCore.
|
||||
*
|
||||
* ProtonCore is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* ProtonCore is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.core.auth.presentation.compose.sso
|
||||
|
||||
public sealed interface BackupPasswordChangeState {
|
||||
public data object Idle : BackupPasswordChangeState
|
||||
public data object Loading : BackupPasswordChangeState
|
||||
|
||||
public data class FormError(val cause: PasswordFormError) : BackupPasswordChangeState
|
||||
|
||||
public data class Error(val message: String?) : BackupPasswordChangeState
|
||||
public data class Close(val message: String?) : BackupPasswordChangeState
|
||||
|
||||
public data object Success : BackupPasswordChangeState
|
||||
}
|
||||
|
||||
internal fun BackupPasswordChangeState.formErrorOrNull(): PasswordFormError? =
|
||||
(this as? BackupPasswordChangeState.FormError)?.cause
|
||||
|
||||
internal fun BackupPasswordChangeState.isPasswordTooShort(): Boolean =
|
||||
formErrorOrNull() == PasswordFormError.PasswordTooShort
|
||||
|
||||
internal fun BackupPasswordChangeState.arePasswordsNotMatching(): Boolean =
|
||||
formErrorOrNull() == PasswordFormError.PasswordsDoNotMatch
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright (c) 2024 Proton AG
|
||||
* This file is part of Proton AG and ProtonCore.
|
||||
*
|
||||
* ProtonCore is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* ProtonCore is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.core.auth.presentation.compose.sso
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.core.auth.domain.LogTag
|
||||
import me.proton.core.auth.domain.usecase.sso.ChangeBackupPassword
|
||||
import me.proton.core.auth.presentation.compose.DeviceSecretRoutes.Arg.getUserId
|
||||
import me.proton.core.auth.presentation.compose.sso.BackupPasswordChangeAction.ChangePassword
|
||||
import me.proton.core.auth.presentation.compose.sso.BackupPasswordChangeState.Error
|
||||
import me.proton.core.auth.presentation.compose.sso.BackupPasswordChangeState.FormError
|
||||
import me.proton.core.auth.presentation.compose.sso.BackupPasswordChangeState.Idle
|
||||
import me.proton.core.auth.presentation.compose.sso.BackupPasswordChangeState.Loading
|
||||
import me.proton.core.auth.presentation.compose.sso.BackupPasswordChangeState.Success
|
||||
import me.proton.core.compose.viewmodel.stopTimeoutMillis
|
||||
import me.proton.core.crypto.common.context.CryptoContext
|
||||
import me.proton.core.crypto.common.keystore.encrypt
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.presentation.utils.InputValidationResult
|
||||
import me.proton.core.presentation.utils.ValidationType
|
||||
import me.proton.core.presentation.utils.onFailure
|
||||
import me.proton.core.presentation.utils.onSuccess
|
||||
import me.proton.core.util.kotlin.catchAll
|
||||
import me.proton.core.util.kotlin.catchWhen
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
public class BackupPasswordChangeViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val context: CryptoContext,
|
||||
private val changeBackupPassword: ChangeBackupPassword
|
||||
) : ViewModel() {
|
||||
|
||||
private val userId: UserId by lazy { savedStateHandle.getUserId() }
|
||||
|
||||
private val mutableAction = MutableStateFlow<BackupPasswordChangeAction?>(null)
|
||||
|
||||
public val state: StateFlow<BackupPasswordChangeState> = mutableAction.flatMapLatest { action ->
|
||||
when (action) {
|
||||
null -> flowOf(Idle)
|
||||
is ChangePassword -> onValidatePassword(action)
|
||||
}
|
||||
}.stateIn(viewModelScope, WhileSubscribed(stopTimeoutMillis), Idle)
|
||||
|
||||
public fun submit(action: BackupPasswordChangeAction): Job = viewModelScope.launch {
|
||||
mutableAction.emit(action)
|
||||
}
|
||||
|
||||
private fun onValidatePassword(action: ChangePassword) = flow {
|
||||
emit(Loading)
|
||||
InputValidationResult(
|
||||
text = action.backupPassword,
|
||||
validationType = ValidationType.PasswordMinLength
|
||||
).onFailure {
|
||||
emit(FormError(PasswordFormError.PasswordTooShort))
|
||||
}.onSuccess {
|
||||
InputValidationResult(
|
||||
text = action.backupPassword,
|
||||
validationType = ValidationType.PasswordMatch,
|
||||
additionalText = action.repeatBackupPassword
|
||||
).onFailure {
|
||||
emit(FormError(PasswordFormError.PasswordsDoNotMatch))
|
||||
}.onSuccess {
|
||||
emitAll(onChangeBackupPassword(action.backupPassword))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onChangeBackupPassword(backupPassword: String) = flow {
|
||||
emit(Loading)
|
||||
val password = backupPassword.encrypt(context.keyStoreCrypto)
|
||||
changeBackupPassword.invoke(userId, password)
|
||||
emit(Success)
|
||||
}.catchWhen(Throwable::isActionNotAllowed) {
|
||||
emit(BackupPasswordChangeState.Close(it.message))
|
||||
}.catchAll(LogTag.CHANGE_BACKUP_PASSWORD) {
|
||||
emit(Error(it.message))
|
||||
}
|
||||
}
|
||||
-6
@@ -112,9 +112,3 @@ public class BackupPasswordInputViewModel @Inject constructor(
|
||||
emit(BackupPasswordInputState.Error(it.message))
|
||||
}
|
||||
}
|
||||
|
||||
private fun Throwable.isActionNotAllowed(): Boolean {
|
||||
if (this !is ApiException) return false
|
||||
val error = error as? ApiResult.Error.Http
|
||||
return error?.proton?.code == ResponseCodes.NOT_ALLOWED
|
||||
}
|
||||
|
||||
+4
-4
@@ -153,7 +153,7 @@ public fun BackupPasswordSetupScaffold(
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(ProtonDimens.DefaultSpacing),
|
||||
) {
|
||||
SsoOrganizationAdminInfoHeader(
|
||||
OrganizationAdminInfoHeader(
|
||||
organizationAdminEmail = organizationAdminEmail,
|
||||
organizationIcon = organizationIcon,
|
||||
organizationName = organizationName
|
||||
@@ -166,7 +166,7 @@ public fun BackupPasswordSetupScaffold(
|
||||
val errorTooShort = stringResource(R.string.backup_password_setup_password_too_short)
|
||||
val errorNotMatch = stringResource(R.string.backup_password_setup_password_not_matching)
|
||||
|
||||
SsoBackupPasswordSetupForm(
|
||||
BackupPasswordSetupForm(
|
||||
backupPasswordError = errorTooShort.takeIf { isPasswordTooShort },
|
||||
backupPasswordRepeatedError = errorNotMatch.takeIf { arePasswordsNotMatching },
|
||||
onContinueClicked = onContinueClicked,
|
||||
@@ -181,7 +181,7 @@ public fun BackupPasswordSetupScaffold(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SsoOrganizationAdminInfoHeader(
|
||||
private fun OrganizationAdminInfoHeader(
|
||||
organizationAdminEmail: String?,
|
||||
organizationIcon: Any?,
|
||||
organizationName: String?,
|
||||
@@ -232,7 +232,7 @@ private fun SsoOrganizationAdminInfoHeader(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SsoBackupPasswordSetupForm(
|
||||
private fun BackupPasswordSetupForm(
|
||||
backupPasswordError: String?,
|
||||
backupPasswordRepeatedError: String?,
|
||||
isLoading: Boolean,
|
||||
|
||||
+4
-9
@@ -36,7 +36,7 @@ public sealed class BackupPasswordSetupState(
|
||||
|
||||
public data class FormError(
|
||||
override val data: BackupPasswordSetupData,
|
||||
val cause: BackupPasswordSetupFormError
|
||||
val cause: PasswordFormError
|
||||
) : BackupPasswordSetupState(data)
|
||||
|
||||
public data class Success(
|
||||
@@ -44,16 +44,11 @@ public sealed class BackupPasswordSetupState(
|
||||
) : BackupPasswordSetupState(data)
|
||||
}
|
||||
|
||||
internal fun BackupPasswordSetupState.formErrorOrNull(): BackupPasswordSetupFormError? =
|
||||
internal fun BackupPasswordSetupState.formErrorOrNull(): PasswordFormError? =
|
||||
(this as? BackupPasswordSetupState.FormError)?.cause
|
||||
|
||||
internal fun BackupPasswordSetupState.isPasswordTooShort(): Boolean =
|
||||
formErrorOrNull() == BackupPasswordSetupFormError.PasswordTooShort
|
||||
formErrorOrNull() == PasswordFormError.PasswordTooShort
|
||||
|
||||
internal fun BackupPasswordSetupState.arePasswordsNotMatching(): Boolean =
|
||||
formErrorOrNull() == BackupPasswordSetupFormError.PasswordsDoNotMatch
|
||||
|
||||
public sealed interface BackupPasswordSetupFormError {
|
||||
public data object PasswordTooShort : BackupPasswordSetupFormError
|
||||
public data object PasswordsDoNotMatch : BackupPasswordSetupFormError
|
||||
}
|
||||
formErrorOrNull() == PasswordFormError.PasswordsDoNotMatch
|
||||
|
||||
+2
-2
@@ -124,14 +124,14 @@ public class BackupPasswordSetupViewModel @Inject constructor(
|
||||
text = action.backupPassword,
|
||||
validationType = ValidationType.PasswordMinLength
|
||||
).onFailure {
|
||||
emit(FormError(state.value.data, BackupPasswordSetupFormError.PasswordTooShort))
|
||||
emit(FormError(state.value.data, PasswordFormError.PasswordTooShort))
|
||||
}.onSuccess {
|
||||
InputValidationResult(
|
||||
text = action.backupPassword,
|
||||
validationType = ValidationType.PasswordMatch,
|
||||
additionalText = action.repeatBackupPassword
|
||||
).onFailure {
|
||||
emit(FormError(state.value.data, BackupPasswordSetupFormError.PasswordsDoNotMatch))
|
||||
emit(FormError(state.value.data, PasswordFormError.PasswordsDoNotMatch))
|
||||
}.onSuccess {
|
||||
when (val organizationPublicKey = state.value.data.organizationPublicKey) {
|
||||
null -> emit(Error(state.value.data, null))
|
||||
|
||||
+5
-3
@@ -47,6 +47,8 @@ import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Devices
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -55,7 +57,6 @@ import me.proton.core.auth.presentation.compose.R
|
||||
import me.proton.core.auth.presentation.compose.SMALL_SCREEN_HEIGHT
|
||||
import me.proton.core.compose.theme.ProtonDimens
|
||||
import me.proton.core.compose.theme.ProtonTheme
|
||||
import me.proton.core.compose.theme.defaultNorm
|
||||
|
||||
@Composable
|
||||
internal fun ConfirmationDigits(
|
||||
@@ -116,7 +117,8 @@ private fun ConfirmationCodeDigit(
|
||||
Text(
|
||||
textAlign = TextAlign.Center,
|
||||
text = digit.toString(),
|
||||
style = textStyle
|
||||
style = textStyle,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -159,7 +161,7 @@ internal fun ConfirmationDigitTextField(
|
||||
true -> BorderStroke(1.dp, ProtonTheme.colors.interactionNorm)
|
||||
false -> BorderStroke(1.dp, ProtonTheme.colors.separatorNorm)
|
||||
},
|
||||
digitStyle = ProtonTheme.typography.defaultNorm
|
||||
digitStyle = ProtonTheme.typography.hero.copy(fontWeight = FontWeight.Normal)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -191,7 +191,11 @@ private fun ConfirmationCodeInputScreen(
|
||||
) {
|
||||
var confirmationCode by remember { mutableStateOf("") }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var selected by remember(data) { mutableStateOf(data.pendingDevices.getOrNull(0)) }
|
||||
var selected by remember { mutableStateOf<AuthDeviceData?>(null) }
|
||||
when (selected?.deviceId) {
|
||||
null -> selected = data.pendingDevices.firstOrNull()
|
||||
!in data.pendingDevices.map { it.deviceId } -> selected = data.pendingDevices.firstOrNull()
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(ProtonDimens.DefaultSpacing)) {
|
||||
Text(
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package me.proton.core.auth.presentation.compose.sso
|
||||
|
||||
public sealed interface PasswordFormError {
|
||||
public data object PasswordTooShort : PasswordFormError
|
||||
public data object PasswordsDoNotMatch : PasswordFormError
|
||||
}
|
||||
+7
-1
@@ -32,11 +32,13 @@ import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import me.proton.core.auth.domain.LogTag
|
||||
import me.proton.core.auth.domain.entity.AuthDeviceState
|
||||
import me.proton.core.auth.domain.repository.AuthDeviceRepository
|
||||
import me.proton.core.auth.domain.repository.DeviceSecretRepository
|
||||
import me.proton.core.auth.presentation.compose.DeviceApprovalRoutes.Arg.getUserId
|
||||
import me.proton.core.auth.presentation.compose.sso.RequestAdminHelpAction.Load
|
||||
import me.proton.core.auth.presentation.compose.sso.RequestAdminHelpAction.Submit
|
||||
import me.proton.core.auth.presentation.compose.sso.RequestAdminHelpState.AdminHelpHelpRequested
|
||||
import me.proton.core.auth.presentation.compose.sso.RequestAdminHelpState.Error
|
||||
import me.proton.core.auth.presentation.compose.sso.RequestAdminHelpState.Idle
|
||||
import me.proton.core.auth.presentation.compose.sso.RequestAdminHelpState.Loading
|
||||
@@ -86,8 +88,12 @@ public class RequestAdminHelpViewModel @Inject constructor(
|
||||
private fun onSubmit() = flow {
|
||||
emit(Loading(state.value.data))
|
||||
val deviceId = requireNotNull(deviceSecretRepository.getByUserId(userId)?.deviceId)
|
||||
val device = authDeviceRepository.getByDeviceId(userId, deviceId)
|
||||
check(device?.state != AuthDeviceState.PendingAdminActivation) {
|
||||
"Device is already pending admin activation."
|
||||
}
|
||||
authDeviceRepository.requestAdminHelp(userId, deviceId)
|
||||
emit(RequestAdminHelpState.AdminHelpHelpRequested(state.value.data))
|
||||
emit(AdminHelpHelpRequested(state.value.data))
|
||||
}.catch { error ->
|
||||
emit(Error(state.value.data, error))
|
||||
}
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package me.proton.core.auth.presentation.compose.sso
|
||||
|
||||
import me.proton.core.network.domain.ApiException
|
||||
import me.proton.core.network.domain.ApiResult
|
||||
import me.proton.core.network.domain.ResponseCodes
|
||||
|
||||
public fun Throwable.isActionNotAllowed(): Boolean {
|
||||
if (this !is ApiException) return false
|
||||
val error = error as? ApiResult.Error.Http
|
||||
return error?.proton?.code == ResponseCodes.NOT_ALLOWED
|
||||
}
|
||||
+1
-2
@@ -130,8 +130,7 @@ public fun WaitingAdminScaffold(
|
||||
|
||||
if (username != null) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(top = ProtonDimens.MediumSpacing),
|
||||
modifier = Modifier.padding(top = ProtonDimens.MediumSpacing),
|
||||
text = stringResource(
|
||||
id = R.string.auth_login_share_confirmation_code_with_admin_subtitle,
|
||||
username
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
<string name="backup_password_input_label">Backup password</string>
|
||||
<string name="backup_password_input_subtitle">To make sure it’s really you trying to sign-in, please enter your backup password.</string>
|
||||
<string name="backup_password_input_title">Enter your backup password</string>
|
||||
|
||||
<string name="backup_password_setup_title" comment="1 - company name">Join %1$s</string>
|
||||
<string name="backup_password_setup_subtitle" comment="1 - email address of organization's admin">%1$s added you to their Proton organization.</string>
|
||||
<string name="backup_password_setup_description">Set a backup password to add an extra layer of protection.</string>
|
||||
@@ -64,6 +65,10 @@
|
||||
<string name="backup_password_setup_continue_action">Continue</string>
|
||||
<string name="backup_password_setup_password_too_short">Password should be at least 8 characters long.</string>
|
||||
<string name="backup_password_setup_password_not_matching">Passwords do not match.</string>
|
||||
|
||||
<string name="backup_password_change_title">Set backup password</string>
|
||||
<string name="backup_password_change_description">This password adds an extra layer of protection and allows you to sign in if you get locked out. Make sure to keep it somewhere safe.</string>
|
||||
|
||||
<string name="backup_password_invalid">Invalid backup password.</string>
|
||||
<string name="backup_password_no_primary_key">No Primary Key.</string>
|
||||
<string name="backup_password_no_key_salts">No Key Salts.</string>
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (c) 2024 Proton AG
|
||||
* This file is part of Proton AG and ProtonCore.
|
||||
*
|
||||
* ProtonCore is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* ProtonCore is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.proton.core.auth.presentation.compose.sso
|
||||
|
||||
import app.cash.paparazzi.DeviceConfig
|
||||
import app.cash.paparazzi.Paparazzi
|
||||
import me.proton.core.compose.theme.ProtonTheme
|
||||
import org.junit.Rule
|
||||
import kotlin.test.Test
|
||||
|
||||
class BackupPasswordChangeScreenTest {
|
||||
@get:Rule
|
||||
val paparazzi = Paparazzi(
|
||||
deviceConfig = DeviceConfig.PIXEL_5,
|
||||
theme = "ProtonTheme"
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `idle screen`() {
|
||||
paparazzi.snapshot {
|
||||
ProtonTheme {
|
||||
BackupPasswordChangeScreen(
|
||||
state = BackupPasswordChangeState.Idle
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `password too short`() {
|
||||
paparazzi.snapshot {
|
||||
ProtonTheme {
|
||||
BackupPasswordChangeScreen(
|
||||
state = BackupPasswordChangeState.FormError(PasswordFormError.PasswordTooShort)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `passwords do not match`() {
|
||||
paparazzi.snapshot {
|
||||
ProtonTheme {
|
||||
BackupPasswordChangeScreen(
|
||||
state = BackupPasswordChangeState.FormError(PasswordFormError.PasswordsDoNotMatch)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -73,7 +73,7 @@ class BackupPasswordSetupScreenTest {
|
||||
organizationIcon = null,
|
||||
organizationName = "Example Organization",
|
||||
),
|
||||
cause = BackupPasswordSetupFormError.PasswordTooShort
|
||||
cause = PasswordFormError.PasswordTooShort
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -91,7 +91,7 @@ class BackupPasswordSetupScreenTest {
|
||||
organizationIcon = null,
|
||||
organizationName = "Example Organization",
|
||||
),
|
||||
cause = BackupPasswordSetupFormError.PasswordsDoNotMatch
|
||||
cause = PasswordFormError.PasswordsDoNotMatch
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -78,7 +78,7 @@ class BackupPasswordSetupViewModelTest : CoroutinesTest by CoroutinesTest() {
|
||||
assertEquals(
|
||||
expected = BackupPasswordSetupState.FormError(
|
||||
data = BackupPasswordSetupData(),
|
||||
cause = BackupPasswordSetupFormError.PasswordTooShort
|
||||
cause = PasswordFormError.PasswordTooShort
|
||||
),
|
||||
actual = awaitItem()
|
||||
)
|
||||
@@ -98,7 +98,7 @@ class BackupPasswordSetupViewModelTest : CoroutinesTest by CoroutinesTest() {
|
||||
assertEquals(
|
||||
expected = BackupPasswordSetupState.FormError(
|
||||
data = BackupPasswordSetupData(),
|
||||
cause = BackupPasswordSetupFormError.PasswordsDoNotMatch
|
||||
cause = PasswordFormError.PasswordsDoNotMatch
|
||||
),
|
||||
actual = awaitItem()
|
||||
)
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c6466c90dc230eb54de9315d3b1ab4cd6f2fbede844a77ff7a211d8235e09075
|
||||
size 34830
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7921b5eb814192416f9b94ab0c7d23f612f52be1d1c8c6c97a8151d2542f304b
|
||||
size 39800
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e400d8764979a996f762ff8c2829e8a47f50ba0fb5aea5f5f1ab4f2174ae50ed
|
||||
size 38018
|
||||
+1
@@ -95,6 +95,7 @@ class ChooseExternalEmailViewModelTest : ArchTest by ArchTest(),
|
||||
delinquent = null,
|
||||
recovery = null,
|
||||
keys = emptyList(),
|
||||
flags = emptyMap(),
|
||||
type = Type.Proton
|
||||
)
|
||||
|
||||
|
||||
+1
@@ -167,6 +167,7 @@ class SignupViewModelTest : ArchTest by ArchTest(), CoroutinesTest by Coroutines
|
||||
delinquent = null,
|
||||
recovery = null,
|
||||
keys = emptyList(),
|
||||
flags = emptyMap(),
|
||||
type = Type.Proton
|
||||
)
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ fun UserId.userEntity() = UserEntity(
|
||||
delinquent = null,
|
||||
recovery = null,
|
||||
passphrase = null,
|
||||
flags = emptyMap(),
|
||||
maxBaseSpace = null,
|
||||
maxDriveSpace = null,
|
||||
usedBaseSpace = null,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -202,7 +202,7 @@ abstract class AppDatabase :
|
||||
|
||||
companion object {
|
||||
const val name = "db-account-manager"
|
||||
const val version = 56
|
||||
const val version = 57
|
||||
|
||||
val migrations = listOf(
|
||||
AppDatabaseMigrations.MIGRATION_1_2,
|
||||
@@ -259,7 +259,8 @@ abstract class AppDatabase :
|
||||
AppDatabaseMigrations.MIGRATION_52_53,
|
||||
AppDatabaseMigrations.MIGRATION_53_54,
|
||||
AppDatabaseMigrations.MIGRATION_54_55,
|
||||
AppDatabaseMigrations.MIGRATION_55_56
|
||||
AppDatabaseMigrations.MIGRATION_55_56,
|
||||
AppDatabaseMigrations.MIGRATION_56_57,
|
||||
)
|
||||
|
||||
fun buildDatabase(context: Context): AppDatabase =
|
||||
|
||||
+7
@@ -393,4 +393,11 @@ object AppDatabaseMigrations {
|
||||
MailSettingsDatabase.MIGRATION_1.migrate(db)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_56_57 = object : Migration(56, 57) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
UserDatabase.MIGRATION_6.migrate(db)
|
||||
AccountDatabase.MIGRATION_9.migrate(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import androidx.room.TypeConverter
|
||||
import me.proton.core.domain.entity.Product
|
||||
import me.proton.core.domain.entity.UserId
|
||||
import me.proton.core.network.domain.session.SessionId
|
||||
import me.proton.core.util.kotlin.deserializeMapOrNull
|
||||
import me.proton.core.util.kotlin.serialize
|
||||
|
||||
class CommonConverters {
|
||||
|
||||
@@ -37,6 +39,12 @@ class CommonConverters {
|
||||
@TypeConverter
|
||||
fun fromStringToListOfInt(value: String?): List<Int>? = Companion.fromStringToListOfInt(value)
|
||||
|
||||
@TypeConverter
|
||||
fun fromMapOfStringBooleanToString(value: Map<String, Boolean>?): String? = value.orEmpty().serialize()
|
||||
|
||||
@TypeConverter
|
||||
fun fromStringToMapOfStringBoolean(value: String?): Map<String, Boolean>? = value?.deserializeMapOrNull<String, Boolean>().orEmpty()
|
||||
|
||||
@TypeConverter
|
||||
fun fromProductToString(value: Product?): String? = value?.name
|
||||
|
||||
|
||||
+8
-6
@@ -21,19 +21,20 @@ package me.proton.core.key.data.api.request
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import me.proton.core.auth.data.api.request.Fido2Request
|
||||
import me.proton.core.crypto.common.pgp.Based64Encoded
|
||||
|
||||
@Serializable
|
||||
data class UpdateKeysForPasswordChangeRequest(
|
||||
@SerialName("KeySalt")
|
||||
val keySalt: String,
|
||||
@SerialName("ClientEphemeral")
|
||||
val clientEphemeral: String,
|
||||
val clientEphemeral: String? = null,
|
||||
@SerialName("ClientProof")
|
||||
val clientProof: String,
|
||||
val clientProof: String? = null,
|
||||
@SerialName("SRPSession")
|
||||
val srpSession: String,
|
||||
val srpSession: String? = null,
|
||||
@SerialName("TwoFactorCode")
|
||||
val twoFactorCode: String?,
|
||||
val twoFactorCode: String? = null,
|
||||
@SerialName("FIDO2")
|
||||
val fido2: Fido2Request? = null,
|
||||
@SerialName("Auth")
|
||||
@@ -41,7 +42,9 @@ data class UpdateKeysForPasswordChangeRequest(
|
||||
@SerialName("Keys")
|
||||
val keys: List<PrivateKeyRequest>? = null,
|
||||
@SerialName("UserKeys")
|
||||
val userKeys: List<PrivateKeyRequest>? = null
|
||||
val userKeys: List<PrivateKeyRequest>? = null,
|
||||
@SerialName("EncryptedSecret")
|
||||
val encryptedSecret: Based64Encoded? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -51,4 +54,3 @@ data class PrivateKeyRequest(
|
||||
@SerialName("ID")
|
||||
val id: String
|
||||
)
|
||||
|
||||
|
||||
@@ -65,6 +65,8 @@ data class UserResponse(
|
||||
val recovery: UserRecoveryResponse? = null,
|
||||
@SerialName("Keys")
|
||||
val keys: List<UserKeyResponse>,
|
||||
@SerialName("Flags")
|
||||
val flags: Map<String, Boolean>? = null,
|
||||
@SerialName("MaxBaseSpace")
|
||||
val maxBaseSpace: Long? = null,
|
||||
@SerialName("MaxDriveSpace")
|
||||
|
||||
+9
-7
@@ -109,19 +109,20 @@ class PrivateKeyRepositoryImpl @Inject constructor(
|
||||
override suspend fun updatePrivateKeys(
|
||||
sessionUserId: SessionUserId,
|
||||
keySalt: String,
|
||||
srpProofs: SrpProofs,
|
||||
srpSession: String,
|
||||
srpProofs: SrpProofs?,
|
||||
srpSession: String?,
|
||||
secondFactorProof: SecondFactorProof?,
|
||||
auth: Auth?,
|
||||
keys: List<Key>?,
|
||||
userKeys: List<Key>?
|
||||
userKeys: List<Key>?,
|
||||
encryptedSecret: Based64Encoded?
|
||||
): Boolean {
|
||||
return provider.get<KeyApi>(sessionUserId).invoke {
|
||||
val response = updatePrivateKeys(
|
||||
UpdateKeysForPasswordChangeRequest(
|
||||
keySalt = keySalt,
|
||||
clientEphemeral = srpProofs.clientEphemeral,
|
||||
clientProof = srpProofs.clientProof,
|
||||
clientEphemeral = srpProofs?.clientEphemeral,
|
||||
clientProof = srpProofs?.clientProof,
|
||||
srpSession = srpSession,
|
||||
twoFactorCode = secondFactorProof.toSecondFactorCode(),
|
||||
fido2 = secondFactorProof.toSecondFactorFido(),
|
||||
@@ -131,10 +132,11 @@ class PrivateKeyRepositoryImpl @Inject constructor(
|
||||
},
|
||||
userKeys = userKeys?.map {
|
||||
PrivateKeyRequest(privateKey = it.privateKey, id = it.keyId.id)
|
||||
}
|
||||
},
|
||||
encryptedSecret = encryptedSecret
|
||||
)
|
||||
)
|
||||
validateServerProof(response.serverProof, srpProofs.expectedServerProof) { "key update failed" }
|
||||
validateServerProof(response.serverProof, srpProofs?.expectedServerProof) { "key update failed" }
|
||||
response.isSuccess()
|
||||
}.valueOrThrow
|
||||
}
|
||||
|
||||
+6
-5
@@ -59,12 +59,13 @@ interface PrivateKeyRepository {
|
||||
suspend fun updatePrivateKeys(
|
||||
sessionUserId: SessionUserId,
|
||||
keySalt: String,
|
||||
srpProofs: SrpProofs,
|
||||
srpSession: String,
|
||||
secondFactorProof: SecondFactorProof?,
|
||||
auth: Auth?,
|
||||
srpProofs: SrpProofs? = null,
|
||||
srpSession: String? = null,
|
||||
secondFactorProof: SecondFactorProof? = null,
|
||||
auth: Auth? = null,
|
||||
keys: List<Key>? = null,
|
||||
userKeys: List<Key>? = null
|
||||
userKeys: List<Key>? = null,
|
||||
encryptedSecret: Based64Encoded? = null
|
||||
): Boolean
|
||||
|
||||
suspend fun reactivatePrivateKey(
|
||||
|
||||
@@ -56,6 +56,7 @@ fun UserId.userEntity() = UserEntity(
|
||||
delinquent = null,
|
||||
recovery = null,
|
||||
passphrase = null,
|
||||
flags = emptyMap(),
|
||||
maxBaseSpace = null,
|
||||
maxDriveSpace = null,
|
||||
usedBaseSpace = null,
|
||||
|
||||
+1
@@ -48,6 +48,7 @@ internal fun testUserEntity(userId: UserId) = UserEntity(
|
||||
delinquent = null,
|
||||
recovery = null,
|
||||
passphrase = null,
|
||||
flags = emptyMap(),
|
||||
maxBaseSpace = null,
|
||||
maxDriveSpace = null,
|
||||
usedBaseSpace = null,
|
||||
|
||||
+1
@@ -213,6 +213,7 @@ class UpgradePlansViewModelTest : ArchTest by ArchTest(), CoroutinesTest by Coro
|
||||
delinquent = null,
|
||||
recovery = null,
|
||||
keys = emptyList(),
|
||||
flags = emptyMap(),
|
||||
type = Type.Proton
|
||||
)
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ internal fun testUserEntity(userId: UserId) = UserEntity(
|
||||
delinquent = null,
|
||||
recovery = null,
|
||||
passphrase = null,
|
||||
flags = emptyMap(),
|
||||
maxBaseSpace = null,
|
||||
maxDriveSpace = null,
|
||||
usedBaseSpace = null,
|
||||
|
||||
+2
-1
@@ -86,7 +86,8 @@ class PerformUpdateUserPassword @Inject constructor(
|
||||
secondFactorProof = secondFactorProof,
|
||||
proofs = clientProofs,
|
||||
srpSession = loginInfo.srpSession,
|
||||
auth = auth
|
||||
auth = auth,
|
||||
encryptedSecret = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -91,7 +91,8 @@ class PerformResetUserPasswordTest {
|
||||
subscribed = 0,
|
||||
delinquent = null,
|
||||
recovery = null,
|
||||
keys = emptyList()
|
||||
keys = emptyList(),
|
||||
flags = emptyMap(),
|
||||
)
|
||||
// endregion
|
||||
|
||||
|
||||
+2
-1
@@ -95,7 +95,8 @@ class PerformUpdateLoginPasswordTest {
|
||||
subscribed = 0,
|
||||
delinquent = null,
|
||||
recovery = null,
|
||||
keys = emptyList()
|
||||
keys = emptyList(),
|
||||
flags = emptyMap(),
|
||||
)
|
||||
|
||||
private val testUserSettingsResponse = UserSettings.nil(testUserId).copy(
|
||||
|
||||
+6
-3
@@ -93,7 +93,8 @@ class PerformUpdateUserPasswordTest {
|
||||
subscribed = 0,
|
||||
delinquent = null,
|
||||
recovery = null,
|
||||
keys = emptyList()
|
||||
keys = emptyList(),
|
||||
flags = emptyMap(),
|
||||
)
|
||||
// endregion
|
||||
|
||||
@@ -121,7 +122,8 @@ class PerformUpdateUserPasswordTest {
|
||||
secondFactorProof = any(),
|
||||
proofs = any(),
|
||||
srpSession = any(),
|
||||
auth = any()
|
||||
auth = any(),
|
||||
encryptedSecret = any()
|
||||
)
|
||||
} returns true
|
||||
|
||||
@@ -199,7 +201,8 @@ class PerformUpdateUserPasswordTest {
|
||||
secondFactorProof = secondFactorFido,
|
||||
proofs = any(),
|
||||
srpSession = any(),
|
||||
auth = any()
|
||||
auth = any(),
|
||||
encryptedSecret = any()
|
||||
)
|
||||
} returns true
|
||||
|
||||
|
||||
+1
@@ -184,6 +184,7 @@ class PasswordManagementFragment :
|
||||
showSuccess()
|
||||
}
|
||||
|
||||
is PasswordManagementViewModel.State.CannotChangePassword -> showError(getString(R.string.settings_password_management_cannot_change_password))
|
||||
is PasswordManagementViewModel.State.Error -> showError(it.error.getUserMessage(resources))
|
||||
}
|
||||
}.launchIn(viewLifecycleOwner.lifecycleScope)
|
||||
|
||||
+19
-1
@@ -45,6 +45,8 @@ import me.proton.core.observability.domain.ObservabilityManager
|
||||
import me.proton.core.observability.domain.metrics.AccountRecoveryResetTotal
|
||||
import me.proton.core.presentation.viewmodel.ProtonViewModel
|
||||
import me.proton.core.user.domain.entity.UserRecovery
|
||||
import me.proton.core.user.domain.extension.isSso
|
||||
import me.proton.core.user.domain.usecase.ObserveUser
|
||||
import me.proton.core.usersettings.domain.usecase.IsSessionAccountRecoveryEnabled
|
||||
import me.proton.core.usersettings.domain.usecase.ObserveUserSettings
|
||||
import me.proton.core.usersettings.domain.usecase.PerformUpdateLoginPassword
|
||||
@@ -56,6 +58,7 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class PasswordManagementViewModel @Inject constructor(
|
||||
private val keyStoreCrypto: KeyStoreCrypto,
|
||||
private val observeUser: ObserveUser,
|
||||
private val observeUserRecovery: ObserveUserRecovery,
|
||||
private val observeUserSettings: ObserveUserSettings,
|
||||
private val observeUserRecoverySelfInitiated: ObserveUserRecoverySelfInitiated,
|
||||
@@ -73,6 +76,10 @@ class PasswordManagementViewModel @Inject constructor(
|
||||
private val currentAction = MutableStateFlow<Action?>(null)
|
||||
private val currentUserId = MutableStateFlow<UserId?>(null)
|
||||
|
||||
private val currentUser = currentUserId.filterNotNull().flatMapLatest {
|
||||
observeUser(it)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
private val currentUserRecovery = currentUserId.filterNotNull().flatMapLatest {
|
||||
observeUserRecovery(it)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
@@ -94,7 +101,7 @@ class PasswordManagementViewModel @Inject constructor(
|
||||
userId = userId,
|
||||
loginPasswordAvailable = true,
|
||||
mailboxPasswordAvailable = isMailboxPassword && product != Product.Vpn,
|
||||
recoveryResetAvailable = recoveryResetAvailable(userId),
|
||||
recoveryResetAvailable = recoveryResetAvailable(userId) && !isUserSso,
|
||||
recoveryResetEnabled = isRecoveryResetEnabled,
|
||||
currentLoginPasswordNeeded = !isRecoveryResetEnabled || !isRecoveryInsecure || !isSelfInitiated,
|
||||
twoFactorEnabled = isTwoFactorEnabled,
|
||||
@@ -117,6 +124,8 @@ class PasswordManagementViewModel @Inject constructor(
|
||||
resetPassword(action)
|
||||
}
|
||||
|
||||
isUserSso -> updateBackupPassword(action)
|
||||
|
||||
else -> when (action.type) {
|
||||
PasswordType.Login -> when (isMailboxPassword) {
|
||||
true -> updateLoginPassword(action)
|
||||
@@ -127,6 +136,12 @@ class PasswordManagementViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateBackupPassword(action: Action.UpdatePassword): Flow<State> = flow {
|
||||
// Currently not supported to change password for Global SSO user.
|
||||
emit(State.CannotChangePassword)
|
||||
perform(Action.ObserveState(action.userId))
|
||||
}
|
||||
|
||||
private fun updateLoginPassword(action: Action.UpdatePassword): Flow<State> = flow {
|
||||
emit(State.UpdatingPassword)
|
||||
val encryptedPassword = action.password.encrypt(keyStoreCrypto)
|
||||
@@ -190,8 +205,10 @@ class PasswordManagementViewModel @Inject constructor(
|
||||
return observeState(action.userId)
|
||||
}
|
||||
|
||||
private val user get() = currentUser.value
|
||||
private val userRecovery get() = currentUserRecovery.value
|
||||
private val userSettings get() = currentUserSettings.value
|
||||
private val isUserSso get() = user?.isSso() ?: false
|
||||
private val isSelfInitiated get() = currentSelfInitiated.value
|
||||
private val isMailboxPassword get() = userSettings?.password?.mode == 2
|
||||
private val isTwoFactorEnabled get() = userSettings?.twoFA?.enabled ?: false
|
||||
@@ -241,6 +258,7 @@ class PasswordManagementViewModel @Inject constructor(
|
||||
|
||||
data object TwoFactorNeeded : State()
|
||||
data object UpdatingPassword : State()
|
||||
data object CannotChangePassword : State()
|
||||
data object Success : State()
|
||||
data class Error(val error: Throwable) : State()
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
<string name="settings_password_management_tab_second_password">Second password</string>
|
||||
<string name="settings_password_management_change_password_note">To change either of your passwords, you\'ll need to know your main password. <a href="https://account.proton.me/reset-password">Forgot your main password?</a></string>
|
||||
<string name="settings_password_management_header">Change password</string>
|
||||
<string name="settings_password_management_cannot_change_password">You can\'t change your backup password. Please contact your administrator.</string>
|
||||
<string name="settings_main_password_hint_current">Current main password</string>
|
||||
<string name="settings_main_password_hint_new">New main password</string>
|
||||
<string name="settings_main_password_hint_new_confirm">Confirm new main password</string>
|
||||
|
||||
+5
@@ -37,6 +37,7 @@ import me.proton.core.test.kotlin.CoroutinesTest
|
||||
import me.proton.core.test.kotlin.flowTest
|
||||
import me.proton.core.user.domain.entity.Type
|
||||
import me.proton.core.user.domain.entity.User
|
||||
import me.proton.core.user.domain.usecase.ObserveUser
|
||||
import me.proton.core.usersettings.domain.entity.PasswordSetting
|
||||
import me.proton.core.usersettings.domain.entity.RecoverySetting
|
||||
import me.proton.core.usersettings.domain.entity.TwoFASetting
|
||||
@@ -60,6 +61,7 @@ import kotlin.test.assertTrue
|
||||
|
||||
class PasswordManagementViewModelTest : ArchTest by ArchTest(), CoroutinesTest by CoroutinesTest() {
|
||||
// region mocks
|
||||
private val observeUser = mockk<ObserveUser>()
|
||||
private val observeUserRecovery = mockk<ObserveUserRecovery>()
|
||||
private val observeUserSettings = mockk<ObserveUserSettings>()
|
||||
private val observeUserRecoverySelfInitiated = mockk<ObserveUserRecoverySelfInitiated>()
|
||||
@@ -109,6 +111,7 @@ class PasswordManagementViewModelTest : ArchTest by ArchTest(), CoroutinesTest b
|
||||
delinquent = null,
|
||||
recovery = null,
|
||||
keys = emptyList(),
|
||||
flags = emptyMap(),
|
||||
type = Type.Proton
|
||||
)
|
||||
// endregion
|
||||
@@ -118,12 +121,14 @@ class PasswordManagementViewModelTest : ArchTest by ArchTest(), CoroutinesTest b
|
||||
@Before
|
||||
fun beforeEveryTest() {
|
||||
coEvery { isAccountRecoveryResetEnabled.invoke(testUserId) } returns false
|
||||
coEvery { observeUser.invoke(testUserId) } returns flowOf(testUser)
|
||||
coEvery { observeUserRecovery.invoke(testUserId) } returns flowOf(testUser.recovery)
|
||||
coEvery { observeUserSettings.invoke(testUserId) } returns flowOf(testUserSettingsResponse)
|
||||
coEvery { observeUserRecoverySelfInitiated.invoke(testUserId) } returns flowOf(false)
|
||||
viewModel =
|
||||
PasswordManagementViewModel(
|
||||
keyStoreCrypto,
|
||||
observeUser,
|
||||
observeUserRecovery,
|
||||
observeUserSettings,
|
||||
observeUserRecoverySelfInitiated,
|
||||
|
||||
@@ -45,6 +45,7 @@ object TestUsers {
|
||||
email = "user1@example.com",
|
||||
displayName = "user 1 name",
|
||||
keys = listOf(Key1.response, Key2Inactive.response),
|
||||
flags = emptyMap(),
|
||||
type = Type.Proton.value
|
||||
)
|
||||
|
||||
@@ -93,6 +94,7 @@ object TestUsers {
|
||||
email = "user1@example.com",
|
||||
displayName = "user 1 name",
|
||||
keys = listOf(Key1.response),
|
||||
flags = emptyMap(),
|
||||
type = Type.Proton.value
|
||||
)
|
||||
|
||||
|
||||
+2
-1
@@ -230,7 +230,8 @@ class UserManagerPasswordTests {
|
||||
),
|
||||
srpSession = "test-srp-session",
|
||||
auth = mockk(),
|
||||
secondFactorProof = null
|
||||
secondFactorProof = null,
|
||||
encryptedSecret = null
|
||||
)
|
||||
|
||||
// THEN
|
||||
|
||||
+1
@@ -718,6 +718,7 @@ class UserRepositoryImplTests {
|
||||
delinquent = null,
|
||||
recovery = null,
|
||||
passphrase = null,
|
||||
flags = emptyMap(),
|
||||
maxBaseSpace = null,
|
||||
maxDriveSpace = null,
|
||||
usedBaseSpace = null,
|
||||
|
||||
@@ -31,6 +31,7 @@ import me.proton.core.crypto.common.keystore.decrypt
|
||||
import me.proton.core.crypto.common.keystore.encrypt
|
||||
import me.proton.core.crypto.common.keystore.use
|
||||
import me.proton.core.crypto.common.pgp.Armored
|
||||
import me.proton.core.crypto.common.pgp.Based64Encoded
|
||||
import me.proton.core.crypto.common.pgp.SignatureContext
|
||||
import me.proton.core.crypto.common.srp.Auth
|
||||
import me.proton.core.crypto.common.srp.SrpProofs
|
||||
@@ -153,9 +154,10 @@ class UserManagerImpl @Inject constructor(
|
||||
userId: UserId,
|
||||
newPassword: EncryptedString,
|
||||
secondFactorProof: SecondFactorProof?,
|
||||
proofs: SrpProofs,
|
||||
srpSession: String,
|
||||
auth: Auth?
|
||||
proofs: SrpProofs?,
|
||||
srpSession: String?,
|
||||
auth: Auth?,
|
||||
encryptedSecret: Based64Encoded?
|
||||
): Boolean {
|
||||
newPassword.decrypt(keyStore).toByteArray().use { decryptedNewPassword ->
|
||||
val keySalt = pgp.generateNewKeySalt()
|
||||
@@ -181,7 +183,8 @@ class UserManagerImpl @Inject constructor(
|
||||
secondFactorProof = secondFactorProof,
|
||||
auth = auth,
|
||||
keys = updatedKeys,
|
||||
userKeys = updatedUserKeys
|
||||
userKeys = updatedUserKeys,
|
||||
encryptedSecret = encryptedSecret
|
||||
)
|
||||
|
||||
lock(userId)
|
||||
|
||||
@@ -130,5 +130,19 @@ interface UserDatabase : Database, UserKeyDatabase {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* - Added UserEntity column: flags.
|
||||
*/
|
||||
val MIGRATION_6 = object : DatabaseMigration {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.addTableColumn(
|
||||
table = "UserEntity",
|
||||
column = "flags",
|
||||
type = "TEXT",
|
||||
defaultValue = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ data class UserEntity(
|
||||
@Embedded(prefix = "recovery_")
|
||||
val recovery: UserRecoveryEntity?,
|
||||
val passphrase: EncryptedByteArray?,
|
||||
val flags: Map<String, Boolean>?,
|
||||
val maxBaseSpace: Long?,
|
||||
val maxDriveSpace: Long?,
|
||||
val usedBaseSpace: Long?,
|
||||
|
||||
@@ -51,6 +51,7 @@ fun UserResponse.toUser(): User {
|
||||
delinquent = Delinquent.map[delinquent],
|
||||
recovery = recovery?.toUserRecovery(),
|
||||
keys = keys.map { it.toUserKey(userId) },
|
||||
flags = flags.orEmpty(),
|
||||
maxBaseSpace = maxBaseSpace,
|
||||
maxDriveSpace = maxDriveSpace,
|
||||
usedBaseSpace = usedBaseSpace,
|
||||
@@ -78,6 +79,7 @@ internal fun User.toEntity(passphrase: EncryptedByteArray?) = UserEntity(
|
||||
delinquent = delinquent?.value,
|
||||
recovery = recovery?.toUserRecoveryEntity(),
|
||||
passphrase = passphrase,
|
||||
flags = flags,
|
||||
maxBaseSpace = maxBaseSpace,
|
||||
maxDriveSpace = maxDriveSpace,
|
||||
usedBaseSpace = usedBaseSpace,
|
||||
@@ -103,6 +105,7 @@ internal fun UserEntity.toUser(keys: List<UserKey>) = User(
|
||||
delinquent = Delinquent.map[delinquent],
|
||||
recovery = recovery?.toUserRecovery(),
|
||||
keys = keys,
|
||||
flags = flags.orEmpty(),
|
||||
maxBaseSpace = maxBaseSpace,
|
||||
maxDriveSpace = maxDriveSpace,
|
||||
usedBaseSpace = usedBaseSpace,
|
||||
|
||||
@@ -275,6 +275,7 @@ class UserManagerImplTest {
|
||||
proofs = mockk(),
|
||||
srpSession = "srp",
|
||||
auth = mockk(),
|
||||
encryptedSecret = null
|
||||
)
|
||||
// Then
|
||||
assertFalse(result)
|
||||
@@ -296,6 +297,7 @@ class UserManagerImplTest {
|
||||
proofs = mockk(),
|
||||
srpSession = "srp",
|
||||
auth = mockk(),
|
||||
encryptedSecret = null
|
||||
)
|
||||
// Then
|
||||
coVerify {
|
||||
@@ -325,6 +327,7 @@ class UserManagerImplTest {
|
||||
proofs = mockk(),
|
||||
srpSession = "srp",
|
||||
auth = mockk(),
|
||||
encryptedSecret = null
|
||||
)
|
||||
// Then
|
||||
coVerify {
|
||||
@@ -351,6 +354,7 @@ class UserManagerImplTest {
|
||||
proofs = mockk(),
|
||||
srpSession = "srp",
|
||||
auth = mockk(),
|
||||
encryptedSecret = null
|
||||
)
|
||||
// Then
|
||||
coVerify {
|
||||
|
||||
@@ -138,9 +138,10 @@ interface UserManager {
|
||||
userId: UserId,
|
||||
newPassword: EncryptedString,
|
||||
secondFactorProof: SecondFactorProof?,
|
||||
proofs: SrpProofs,
|
||||
srpSession: String,
|
||||
auth: Auth?
|
||||
proofs: SrpProofs?,
|
||||
srpSession: String?,
|
||||
auth: Auth?,
|
||||
encryptedSecret: Based64Encoded?
|
||||
): Boolean
|
||||
|
||||
/**
|
||||
|
||||
@@ -99,6 +99,7 @@ data class User(
|
||||
* @see [UserManager.lock]
|
||||
* */
|
||||
override val keys: List<UserKey>,
|
||||
val flags: Map<String, Boolean>,
|
||||
val maxBaseSpace: Long? = null,
|
||||
val maxDriveSpace: Long? = null,
|
||||
val usedBaseSpace: Long? = null,
|
||||
|
||||
@@ -95,10 +95,14 @@ fun User.getUsedDriveSpacePercentage(): Int? = getUsedPercentage(usedDriveSpace,
|
||||
fun User.getUsedTotalSpacePercentage(): Int = getUsedPercentage(usedSpace, maxSpace)!!
|
||||
|
||||
/**
|
||||
* @return true if the user have a temporary password.
|
||||
* @return true if the user has a temporary password.
|
||||
*/
|
||||
fun User.hasTemporaryPassword() = false
|
||||
fun User.hasTemporaryPassword(): Boolean = flags.getOrDefault("has-temporary-password", false)
|
||||
|
||||
/**
|
||||
* @return true if the user is SSO.
|
||||
*/
|
||||
fun User.isSso(): Boolean = flags.getOrDefault("sso", false)
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private fun getUsedPercentage(used: Long?, max: Long?): Int? {
|
||||
|
||||
@@ -46,6 +46,7 @@ class UserKtTest {
|
||||
delinquent = null,
|
||||
recovery = null,
|
||||
keys = emptyList(),
|
||||
flags = emptyMap(),
|
||||
type = Type.Proton
|
||||
)
|
||||
|
||||
|
||||
@@ -97,6 +97,16 @@ inline fun <reified T : Any> String.deserializeList(): List<T> = Serializer.deco
|
||||
inline fun <reified T : Any, reified V : Any> String.deserializeMap(): Map<T, V> =
|
||||
Serializer.decodeFromString(this)
|
||||
|
||||
/**
|
||||
* @return [Map] of [T], [V] object from the receiver [String]
|
||||
* This uses reflection: TODO improve for avoid it
|
||||
*/
|
||||
@NeedSerializable
|
||||
inline fun <reified T : Any, reified V : Any> String.deserializeMapOrNull(): Map<T, V>? = try {
|
||||
deserializeMap()
|
||||
} catch (e: SerializationException) {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* @return [String] from the receiver [T] object
|
||||
|
||||
Reference in New Issue
Block a user