mirror of
https://github.com/bitwarden/android.git
synced 2026-06-08 08:06:32 -05:00
BIT-102: Create account functionality (#132)
This commit is contained in:
committed by
Álison Fernandes
parent
6f212066e3
commit
79c953b605
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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].
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user