BIT-102: Create account functionality (#132)

This commit is contained in:
Ramsey Smith
2023-10-19 09:00:39 -06:00
committed by Álison Fernandes
parent 6f212066e3
commit 79c953b605
35 changed files with 1134 additions and 114 deletions

View File

@@ -2,7 +2,7 @@ package com.x8bit.bitwarden
import android.content.Intent
import androidx.lifecycle.ViewModel
import com.x8bit.bitwarden.data.auth.datasource.network.util.getCaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getCaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject

View File

@@ -2,6 +2,8 @@ package com.x8bit.bitwarden.data.auth.datasource.network.api
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
import retrofit2.http.Body
import retrofit2.http.POST
@@ -12,4 +14,7 @@ interface AccountsApi {
@POST("/accounts/prelogin")
suspend fun preLogin(@Body body: PreLoginRequestJson): Result<PreLoginResponseJson>
@POST("/accounts/register")
suspend fun register(@Body body: RegisterRequestJson): Result<RegisterResponseJson.Success>
}

View File

@@ -26,7 +26,8 @@ object NetworkModule {
@Singleton
fun providesAccountService(
@Named(NetworkModule.UNAUTHORIZED) retrofit: Retrofit,
): AccountsService = AccountsServiceImpl(retrofit.create())
json: Json,
): AccountsService = AccountsServiceImpl(retrofit.create(), json)
@Provides
@Singleton

View File

@@ -0,0 +1,60 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson.Keys
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Request body for register.
*
* @param email the email to be registered.
* @param masterPasswordHash the master password (encrypted).
* @param masterPasswordHint the hint for the master password (nullable).
* @param captchaResponse the captcha bypass token.
* @param key the user key for the request (encrypted).
* @param keys a [Keys] object containing public and private keys.
* @param kdfType the kdf type represented as an [Int].
* @param kdfIterations the number of kdf iterations.
*/
@Serializable
data class RegisterRequestJson(
@SerialName("email")
val email: String,
@SerialName("masterPasswordHash")
val masterPasswordHash: String,
@SerialName("masterPasswordHint")
val masterPasswordHint: String?,
@SerialName("captchaResponse")
val captchaResponse: String?,
@SerialName("key")
val key: String,
@SerialName("keys")
val keys: Keys,
@SerialName("kdf")
val kdfType: KdfTypeJson,
@SerialName("kdfIterations")
val kdfIterations: UInt,
) {
/**
* A keys object containing public and private keys.
*
* @param publicKey the public key (encrypted).
* @param encryptedPrivateKey the private key (encrypted).
*/
@Serializable
data class Keys(
@SerialName("publicKey")
val publicKey: String,
@SerialName("encryptedPrivateKey")
val encryptedPrivateKey: String,
)
}

View File

@@ -0,0 +1,45 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Models response bodies for the register request.
*/
@Serializable
sealed class RegisterResponseJson {
/**
* Models a successful json response of the register request.
*
* @param captchaBypassToken the bypass token.
*/
@Serializable
data class Success(
@SerialName("captchaBypassToken")
val captchaBypassToken: String,
) : RegisterResponseJson()
/**
* Models a json body of a captcha error.
*
* @param validationErrors object containing error validations of the response.
*/
@Serializable
data class CaptchaRequired(
@SerialName("validationErrors")
val validationErrors: ValidationErrors,
) : RegisterResponseJson() {
/**
* Error validations containing a HCaptcha Site Key.
*
* @param captchaKeys keys for attempting captcha verification.
*/
@Serializable
data class ValidationErrors(
@SerialName("HCaptcha_SiteKey")
val captchaKeys: List<String>,
)
}
}

View File

@@ -1,6 +1,8 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
/**
* Provides an API for querying accounts endpoints.
@@ -11,4 +13,9 @@ interface AccountsService {
* Make pre login request to get KDF params.
*/
suspend fun preLogin(email: String): Result<PreLoginResponseJson>
/**
* Register a new account to Bitwarden.
*/
suspend fun register(body: RegisterRequestJson): Result<RegisterResponseJson>
}

View File

@@ -3,11 +3,31 @@ package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.api.AccountsApi
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
import com.x8bit.bitwarden.data.platform.datasource.network.model.toBitwardenError
import com.x8bit.bitwarden.data.platform.datasource.network.util.parseErrorBodyOrNull
import kotlinx.serialization.json.Json
import java.net.HttpURLConnection
class AccountsServiceImpl constructor(
private val accountsApi: AccountsApi,
private val json: Json,
) : AccountsService {
override suspend fun preLogin(email: String): Result<PreLoginResponseJson> =
accountsApi.preLogin(PreLoginRequestJson(email = email))
// TODO add error parsing and pass along error message for validations BIT-763
override suspend fun register(body: RegisterRequestJson): Result<RegisterResponseJson> =
accountsApi
.register(body)
.recoverCatching { throwable ->
throwable
.toBitwardenError()
.parseErrorBodyOrNull<RegisterResponseJson.CaptchaRequired>(
code = HttpURLConnection.HTTP_BAD_REQUEST,
json = json,
) ?: throw throwable
}
}

View File

@@ -0,0 +1,15 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk.util
import com.bitwarden.core.Kdf
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson.ARGON2_ID
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson.PBKDF2_SHA256
/**
* Convert a [Kdf] to a [KdfTypeJson].
*/
fun Kdf.toKdfTypeJson(): KdfTypeJson =
when (this) {
is Kdf.Argon2id -> ARGON2_ID
is Kdf.Pbkdf2 -> PBKDF2_SHA256
}

View File

@@ -1,8 +1,9 @@
package com.x8bit.bitwarden.data.auth.repository
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import com.x8bit.bitwarden.data.auth.datasource.network.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
@@ -26,6 +27,12 @@ interface AuthRepository {
*/
var rememberedEmailAddress: String?
/**
* The currently selected region label (`null` if not set).
*/
// TODO replace this with a more robust selected region object BIT-725
var selectedRegionLabel: String
/**
* Attempt to login with the given email and password. Updated access token will be reflected
* in [authStateFlow].
@@ -41,6 +48,16 @@ interface AuthRepository {
*/
fun logout()
/**
* Attempt to register a new account with the given parameters.
*/
suspend fun register(
email: String,
masterPassword: String,
masterPasswordHint: String?,
captchaToken: String?,
): RegisterResult
/**
* Set the value of [captchaTokenResultFlow].
*/

View File

@@ -1,15 +1,20 @@
package com.x8bit.bitwarden.data.auth.repository
import com.bitwarden.core.Kdf
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.CaptchaRequired
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.Success
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.network.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfTypeJson
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor
import com.x8bit.bitwarden.data.platform.util.flatMap
@@ -23,6 +28,8 @@ import kotlinx.coroutines.flow.update
import javax.inject.Inject
import javax.inject.Singleton
private const val DEFAULT_KDF_ITERATIONS = 600000
/**
* Default implementation of [AuthRepository].
*/
@@ -49,6 +56,9 @@ class AuthRepositoryImpl @Inject constructor(
authDiskSource.rememberedEmailAddress = value
}
// TODO Handle selected region functionality BIT-725
override var selectedRegionLabel: String = "bitwarden.us"
override suspend fun login(
email: String,
password: String,
@@ -93,6 +103,56 @@ class AuthRepositoryImpl @Inject constructor(
mutableAuthStateFlow.update { AuthState.Unauthenticated }
}
override suspend fun register(
email: String,
masterPassword: String,
masterPasswordHint: String?,
captchaToken: String?,
): RegisterResult {
val kdf = Kdf.Pbkdf2(DEFAULT_KDF_ITERATIONS.toUInt())
return authSdkSource
.makeRegisterKeys(
email = email,
password = masterPassword,
kdf = kdf,
)
.flatMap { registerKeyResponse ->
accountsService.register(
body = RegisterRequestJson(
email = email,
masterPasswordHash = registerKeyResponse.masterPasswordHash,
masterPasswordHint = masterPasswordHint,
captchaResponse = captchaToken,
key = registerKeyResponse.encryptedUserKey,
keys = RegisterRequestJson.Keys(
publicKey = registerKeyResponse.keys.public,
encryptedPrivateKey = registerKeyResponse.keys.private,
),
kdfType = kdf.toKdfTypeJson(),
kdfIterations = kdf.iterations,
),
)
}
.fold(
onSuccess = {
when (it) {
is RegisterResponseJson.CaptchaRequired -> {
it.validationErrors.captchaKeys.firstOrNull()?.let { key ->
RegisterResult.CaptchaRequired(captchaId = key)
} ?: RegisterResult.Error(errorMessage = null)
}
is RegisterResponseJson.Success -> {
RegisterResult.Success(captchaToken = it.captchaBypassToken)
}
}
},
onFailure = {
RegisterResult.Error(errorMessage = null)
},
)
}
override fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult) {
mutableCaptchaTokenFlow.tryEmit(tokenResult)
}

View File

@@ -1,4 +1,4 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models high level auth state for the application.

View File

@@ -1,4 +1,4 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models result of logging in.

View File

@@ -0,0 +1,27 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models result of registering a new account.
*/
sealed class RegisterResult {
/**
* Register succeeded.
*
* @param captchaToken the captcha bypass token to bypass future captcha verifications.
*/
data class Success(val captchaToken: String) : RegisterResult()
/**
* Captcha verification is required.
*
* @param captchaId the captcha id for performing the captcha verification.
*/
data class CaptchaRequired(val captchaId: String) : RegisterResult()
/**
* There was an error logging in.
*
* @param errorMessage a message describing the error.
*/
data class Error(val errorMessage: String?) : RegisterResult()
}

View File

@@ -1,8 +1,7 @@
package com.x8bit.bitwarden.data.auth.datasource.network.util
package com.x8bit.bitwarden.data.auth.repository.util
import android.content.Intent
import android.net.Uri
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.net.URLEncoder
@@ -15,7 +14,7 @@ private const val CALLBACK_URI = "bitwarden://$CAPTCHA_HOST"
/**
* Generates a [Uri] to display a CAPTCHA challenge for Bitwarden authentication.
*/
fun LoginResult.CaptchaRequired.generateUriForCaptcha(): Uri {
fun generateUriForCaptcha(captchaId: String): Uri {
val json = buildJsonObject {
put(key = "siteKey", value = captchaId)
put(key = "locale", value = Locale.getDefault().toString())

View File

@@ -4,6 +4,7 @@ import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavOptions
import androidx.navigation.navOptions
import androidx.navigation.navigation
import com.x8bit.bitwarden.ui.auth.feature.createaccount.createAccountDestinations
import com.x8bit.bitwarden.ui.auth.feature.createaccount.navigateToCreateAccount
@@ -22,11 +23,25 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) {
startDestination = LANDING_ROUTE,
route = AUTH_GRAPH_ROUTE,
) {
createAccountDestinations(onNavigateBack = { navController.popBackStack() })
createAccountDestinations(
onNavigateBack = { navController.popBackStack() },
onNavigateToLogin = { emailAddress, captchaToken ->
navController.navigateToLogin(
emailAddress = emailAddress,
captchaToken = captchaToken,
navOptions = navOptions {
popUpTo(LANDING_ROUTE)
},
)
},
)
landingDestinations(
onNavigateToCreateAccount = { navController.navigateToCreateAccount() },
onNavigateToLogin = { emailAddress, regionLabel ->
navController.navigateToLogin(emailAddress, regionLabel)
onNavigateToLogin = { emailAddress ->
navController.navigateToLogin(
emailAddress = emailAddress,
captchaToken = null,
)
},
)
loginDestinations(

View File

@@ -19,8 +19,12 @@ fun NavController.navigateToCreateAccount(navOptions: NavOptions? = null) {
*/
fun NavGraphBuilder.createAccountDestinations(
onNavigateBack: () -> Unit,
onNavigateToLogin: (emailAddress: String, captchaToken: String) -> Unit,
) {
composable(route = CREATE_ACCOUNT_ROUTE) {
CreateAccountScreen(onNavigateBack)
CreateAccountScreen(
onNavigateBack = onNavigateBack,
onNavigateToLogin = onNavigateToLogin,
)
}
}

View File

@@ -57,6 +57,7 @@ import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountEvent.Navi
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField
import com.x8bit.bitwarden.ui.platform.components.BitwardenSwitch
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButtonTopAppBar
@@ -70,6 +71,7 @@ import com.x8bit.bitwarden.ui.platform.theme.clickableSpanStyle
@Composable
fun CreateAccountScreen(
onNavigateBack: () -> Unit,
onNavigateToLogin: (emailAddress: String, captchaToken: String) -> Unit,
intentHandler: IntentHandler = IntentHandler(context = LocalContext.current),
viewModel: CreateAccountViewModel = hiltViewModel(),
) {
@@ -89,13 +91,26 @@ fun CreateAccountScreen(
is CreateAccountEvent.ShowToast -> {
Toast.makeText(context, event.text, Toast.LENGTH_SHORT).show()
}
is CreateAccountEvent.NavigateToCaptcha -> {
intentHandler.startCustomTabsActivity(uri = event.uri)
}
is CreateAccountEvent.NavigateToLogin -> {
onNavigateToLogin(
event.email,
event.captchaToken,
)
}
}
}
BitwardenBasicDialog(
visibilityState = state.errorDialogState,
onDismissRequest = remember(viewModel) { { viewModel.trySendAction(ErrorDialogDismiss) } },
)
BitwardenLoadingDialog(
visibilityState = state.loadingDialogState,
)
Column(
modifier = Modifier
.fillMaxSize()

View File

@@ -1,9 +1,14 @@
package com.x8bit.bitwarden.ui.auth.feature.createaccount
import android.net.Uri
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.AcceptPoliciesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.CheckDataBreachesToggle
import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.ConfirmPasswordInputChange
@@ -16,10 +21,12 @@ import com.x8bit.bitwarden.ui.auth.feature.createaccount.CreateAccountAction.Ter
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@@ -33,6 +40,7 @@ private const val MIN_PASSWORD_LENGTH = 12
@HiltViewModel
class CreateAccountViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val authRepository: AuthRepository,
) : BaseViewModel<CreateAccountState, CreateAccountEvent, CreateAccountAction>(
initialState = savedStateHandle[KEY_STATE]
?: CreateAccountState(
@@ -43,6 +51,7 @@ class CreateAccountViewModel @Inject constructor(
isAcceptPoliciesToggled = false,
isCheckDataBreachesToggled = false,
errorDialogState = BasicDialogState.Hidden,
loadingDialogState = LoadingDialogState.Hidden,
),
) {
@@ -51,6 +60,16 @@ class CreateAccountViewModel @Inject constructor(
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
authRepository
.captchaTokenResultFlow
.onEach {
sendAction(
CreateAccountAction.Internal.ReceiveCaptchaToken(
tokenResult = it,
),
)
}
.launchIn(viewModelScope)
}
override fun handleAction(action: CreateAccountAction) {
@@ -66,6 +85,73 @@ class CreateAccountViewModel @Inject constructor(
is CheckDataBreachesToggle -> handleCheckDataBreachesToggle(action)
is PrivacyPolicyClick -> handlePrivacyPolicyClick()
is TermsClick -> handleTermsClick()
is CreateAccountAction.Internal.ReceiveRegisterResult -> {
handleReceiveRegisterAccountResult(action)
}
is CreateAccountAction.Internal.ReceiveCaptchaToken -> {
handleReceiveCaptchaToken(action)
}
}
}
private fun handleReceiveCaptchaToken(
action: CreateAccountAction.Internal.ReceiveCaptchaToken,
) {
when (val result = action.tokenResult) {
is CaptchaCallbackTokenResult.MissingToken -> {
mutableStateFlow.update {
it.copy(
errorDialogState = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = R.string.captcha_failed.asText(),
),
)
}
}
is CaptchaCallbackTokenResult.Success -> {
submitRegisterAccountRequest(captchaToken = result.token)
}
}
}
private fun handleReceiveRegisterAccountResult(
action: CreateAccountAction.Internal.ReceiveRegisterResult,
) {
when (val registerAccountResult = action.registerResult) {
is RegisterResult.CaptchaRequired -> {
mutableStateFlow.update { it.copy(loadingDialogState = LoadingDialogState.Hidden) }
sendEvent(
CreateAccountEvent.NavigateToCaptcha(
uri = generateUriForCaptcha(captchaId = registerAccountResult.captchaId),
),
)
}
is RegisterResult.Error -> {
// TODO show more robust error messages BIT-763
mutableStateFlow.update {
it.copy(
loadingDialogState = LoadingDialogState.Hidden,
errorDialogState = BasicDialogState.Shown(
title = R.string.an_error_has_occurred.asText(),
message = registerAccountResult.errorMessage?.asText()
?: R.string.generic_error_message.asText(),
),
)
}
}
is RegisterResult.Success -> {
mutableStateFlow.update { it.copy(loadingDialogState = LoadingDialogState.Hidden) }
sendEvent(
CreateAccountEvent.NavigateToLogin(
email = mutableStateFlow.value.emailInput,
captchaToken = registerAccountResult.captchaToken,
),
)
}
}
}
@@ -121,7 +207,30 @@ class CreateAccountViewModel @Inject constructor(
}
else -> {
sendEvent(CreateAccountEvent.ShowToast("TODO: Handle Submit Click"))
submitRegisterAccountRequest(captchaToken = null)
}
}
private fun submitRegisterAccountRequest(captchaToken: String?) {
mutableStateFlow.update {
it.copy(
loadingDialogState = LoadingDialogState.Shown(
text = R.string.creating_account.asText(),
),
)
}
viewModelScope.launch {
val result = authRepository.register(
email = mutableStateFlow.value.emailInput,
masterPassword = mutableStateFlow.value.passwordInput,
masterPasswordHint = mutableStateFlow.value.passwordHintInput.ifBlank { null },
captchaToken = captchaToken,
)
sendAction(
CreateAccountAction.Internal.ReceiveRegisterResult(
registerResult = result,
),
)
}
}
}
@@ -138,6 +247,7 @@ data class CreateAccountState(
val isCheckDataBreachesToggled: Boolean,
val isAcceptPoliciesToggled: Boolean,
val errorDialogState: BasicDialogState,
val loadingDialogState: LoadingDialogState,
) : Parcelable
/**
@@ -155,6 +265,19 @@ sealed class CreateAccountEvent {
*/
data class ShowToast(val text: String) : CreateAccountEvent()
/**
* Navigates to the captcha verification screen.
*/
data class NavigateToCaptcha(val uri: Uri) : CreateAccountEvent()
/**
* Navigates to the captcha verification screen.
*/
data class NavigateToLogin(
val email: String,
val captchaToken: String,
) : CreateAccountEvent()
/**
* Navigate to terms and conditions.
*/
@@ -224,4 +347,23 @@ sealed class CreateAccountAction {
* User tapped terms link.
*/
data object TermsClick : CreateAccountAction()
/**
* Models actions that the [CreateAccountViewModel] itself might send.
*/
sealed class Internal : CreateAccountAction() {
/**
* Indicates a captcha callback token has been received.
*/
data class ReceiveCaptchaToken(
val tokenResult: CaptchaCallbackTokenResult,
) : Internal()
/**
* Indicates a [RegisterResult] has been received.
*/
data class ReceiveRegisterResult(
val registerResult: RegisterResult,
) : Internal()
}
}

View File

@@ -19,7 +19,7 @@ fun NavController.navigateToLanding(navOptions: NavOptions? = null) {
*/
fun NavGraphBuilder.landingDestinations(
onNavigateToCreateAccount: () -> Unit,
onNavigateToLogin: (emailAddress: String, regionLabel: String) -> Unit,
onNavigateToLogin: (emailAddress: String) -> Unit,
) {
composable(route = LANDING_ROUTE) {
LandingScreen(

View File

@@ -54,7 +54,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
@Suppress("LongMethod")
fun LandingScreen(
onNavigateToCreateAccount: () -> Unit,
onNavigateToLogin: (emailAddress: String, regionLabel: String) -> Unit,
onNavigateToLogin: (emailAddress: String) -> Unit,
viewModel: LandingViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
@@ -63,7 +63,6 @@ fun LandingScreen(
LandingEvent.NavigateToCreateAccount -> onNavigateToCreateAccount()
is LandingEvent.NavigateToLogin -> onNavigateToLogin(
event.emailAddress,
event.regionLabel,
)
}
}

View File

@@ -66,12 +66,13 @@ class LandingViewModel @Inject constructor(
val email = mutableStateFlow.value.emailInput
val isRememberMeEnabled = mutableStateFlow.value.isRememberMeEnabled
val selectedRegionLabel = mutableStateFlow.value.selectedRegion.label
// Update the remembered email address
authRepository.rememberedEmailAddress = email.takeUnless { !isRememberMeEnabled }
// Update the selected region selectedRegionLabel
authRepository.selectedRegionLabel = mutableStateFlow.value.selectedRegion.label
sendEvent(LandingEvent.NavigateToLogin(email, selectedRegionLabel))
sendEvent(LandingEvent.NavigateToLogin(email))
}
private fun handleCreateAccountClicked() {
@@ -125,7 +126,6 @@ sealed class LandingEvent {
*/
data class NavigateToLogin(
val emailAddress: String,
val regionLabel: String,
) : LandingEvent()
}

View File

@@ -9,16 +9,16 @@ import androidx.navigation.compose.composable
import androidx.navigation.navArgument
private const val EMAIL_ADDRESS: String = "email_address"
private const val REGION_LABEL: String = "region_label"
private const val LOGIN_ROUTE: String = "login/{$EMAIL_ADDRESS}/{$REGION_LABEL}"
private const val CAPTCHA_TOKEN = "captcha_token"
private const val LOGIN_ROUTE: String = "login/{$EMAIL_ADDRESS}/{$CAPTCHA_TOKEN}"
/**
* Class to retrieve login arguments from the [SavedStateHandle].
*/
class LoginArgs(val emailAddress: String, val regionLabel: String) {
class LoginArgs(val emailAddress: String, val captchaToken: String?) {
constructor(savedStateHandle: SavedStateHandle) : this(
checkNotNull(savedStateHandle[EMAIL_ADDRESS]) as String,
checkNotNull(savedStateHandle[REGION_LABEL]) as String,
savedStateHandle[CAPTCHA_TOKEN],
)
}
@@ -27,10 +27,10 @@ class LoginArgs(val emailAddress: String, val regionLabel: String) {
*/
fun NavController.navigateToLogin(
emailAddress: String,
regionLabel: String,
captchaToken: String?,
navOptions: NavOptions? = null,
) {
this.navigate("login/$emailAddress/$regionLabel", navOptions)
this.navigate("login/$emailAddress/$captchaToken", navOptions)
}
/**

View File

@@ -7,9 +7,9 @@ import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import com.x8bit.bitwarden.data.auth.datasource.network.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.datasource.network.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.asText
@@ -38,9 +38,10 @@ class LoginViewModel @Inject constructor(
emailAddress = LoginArgs(savedStateHandle).emailAddress,
isLoginButtonEnabled = true,
passwordInput = "",
region = LoginArgs(savedStateHandle).regionLabel,
region = authRepository.selectedRegionLabel,
loadingDialogState = LoadingDialogState.Hidden,
errorDialogState = BasicDialogState.Hidden,
captchaToken = LoginArgs(savedStateHandle).captchaToken,
),
) {
@@ -84,7 +85,7 @@ class LoginViewModel @Inject constructor(
mutableStateFlow.update { it.copy(loadingDialogState = LoadingDialogState.Hidden) }
sendEvent(
event = LoginEvent.NavigateToCaptcha(
uri = loginResult.generateUriForCaptcha(),
uri = generateUriForCaptcha(captchaId = loginResult.captchaId),
),
)
}
@@ -123,7 +124,12 @@ class LoginViewModel @Inject constructor(
}
}
is CaptchaCallbackTokenResult.Success -> attemptLogin(captchaToken = tokenResult.token)
is CaptchaCallbackTokenResult.Success -> {
mutableStateFlow.update {
it.copy(captchaToken = tokenResult.token)
}
attemptLogin()
}
}
}
@@ -132,10 +138,10 @@ class LoginViewModel @Inject constructor(
}
private fun handleLoginButtonClicked() {
attemptLogin(captchaToken = null)
attemptLogin()
}
private fun attemptLogin(captchaToken: String?) {
private fun attemptLogin() {
mutableStateFlow.update {
it.copy(
loadingDialogState = LoadingDialogState.Shown(
@@ -147,7 +153,7 @@ class LoginViewModel @Inject constructor(
val result = authRepository.login(
email = mutableStateFlow.value.emailAddress,
password = mutableStateFlow.value.passwordInput,
captchaToken = captchaToken,
captchaToken = mutableStateFlow.value.captchaToken,
)
sendAction(
LoginAction.Internal.ReceiveLoginResult(
@@ -183,6 +189,7 @@ class LoginViewModel @Inject constructor(
data class LoginState(
val passwordInput: String,
val emailAddress: String,
val captchaToken: String?,
val region: String,
val isLoginButtonEnabled: Boolean,
val loadingDialogState: LoadingDialogState,

View File

@@ -3,7 +3,7 @@ package com.x8bit.bitwarden.ui.platform.feature.rootnav
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel