Login screens: forget password screens

This commit is contained in:
Benoit Marty 2019-11-15 14:22:49 +01:00
parent b7bfb20a2e
commit 2871e4f5b1
13 changed files with 491 additions and 13 deletions

View file

@ -68,4 +68,9 @@ interface Authenticator {
* Create a session after a SSO successful login
*/
fun createSessionFromSso(credentials: Credentials, homeServerConnectionConfig: HomeServerConnectionConfig, callback: MatrixCallback<Session>): Cancelable
/**
* Reset user password
*/
fun resetPassword(homeServerConnectionConfig: HomeServerConnectionConfig, email: String, newPassword: String, callback: MatrixCallback<Unit>): Cancelable
}

View file

@ -131,6 +131,25 @@ internal class DefaultAuthenticator @Inject constructor(@Unauthenticated
sessionManager.getOrCreateSession(sessionParams)
}
override fun resetPassword(homeServerConnectionConfig: HomeServerConnectionConfig, email: String, newPassword: String, callback: MatrixCallback<Unit>): Cancelable {
val job = GlobalScope.launch(coroutineDispatchers.main) {
val result = runCatching {
resetPasswordInternal(/*homeServerConnectionConfig, email, newPassword*/)
}
result.foldToCallback(callback)
}
return CancelableCoroutine(job)
}
private fun resetPasswordInternal(/*homeServerConnectionConfig: HomeServerConnectionConfig, email: String, newPassword: String*/) {
// TODO
error("Not implemented")
//val authAPI = buildAuthAPI(homeServerConnectionConfig)
//executeRequest<LoginFlowResponse> {
// apiCall = authAPI.getLoginFlows()
//}
}
private fun buildAuthAPI(homeServerConnectionConfig: HomeServerConnectionConfig): AuthAPI {
val retrofit = retrofitFactory.create(okHttpClient, homeServerConnectionConfig.homeServerUri.toString())
return retrofit.create(AuthAPI::class.java)

View file

@ -26,6 +26,7 @@ sealed class LoginAction : VectorViewModelAction {
data class Login(val login: String, val password: String) : LoginAction()
data class WebLoginSuccess(val credentials: Credentials) : LoginAction()
data class InitWith(val loginConfig: LoginConfig) : LoginAction()
data class ResetPassword(val email: String, val newPassword: String) : LoginAction()
// Reset actions
open class ResetAction : LoginAction()
@ -34,4 +35,5 @@ sealed class LoginAction : VectorViewModelAction {
object ResetHomeServerUrl : ResetAction()
object ResetSignMode : ResetAction()
object ResetLogin : ResetAction()
object ResetResetPassword : ResetAction()
}

View file

@ -65,11 +65,17 @@ class LoginActivity : VectorBaseActivity() {
loginSharedActionViewModel.observe()
.subscribe {
when (it) {
is LoginNavigation.OpenServerSelection -> addFragmentToBackstack(R.id.loginFragmentContainer, LoginServerSelectionFragment::class.java)
is LoginNavigation.OnServerSelectionDone -> onServerSelectionDone()
is LoginNavigation.OnSignModeSelected -> onSignModeSelected()
is LoginNavigation.OnLoginFlowRetrieved -> onLoginFlowRetrieved()
is LoginNavigation.OnWebLoginError -> onWebLoginError(it)
is LoginNavigation.OpenServerSelection -> addFragmentToBackstack(R.id.loginFragmentContainer, LoginServerSelectionFragment::class.java)
is LoginNavigation.OnServerSelectionDone -> onServerSelectionDone()
is LoginNavigation.OnSignModeSelected -> onSignModeSelected()
is LoginNavigation.OnLoginFlowRetrieved -> onLoginFlowRetrieved()
is LoginNavigation.OnWebLoginError -> onWebLoginError(it)
is LoginNavigation.OnForgetPasswordClicked -> addFragmentToBackstack(R.id.loginFragmentContainer, LoginResetPasswordFragment::class.java)
is LoginNavigation.OnResetPasswordSuccess -> {
supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
addFragmentToBackstack(R.id.loginFragmentContainer, LoginResetPasswordSuccessFragment::class.java)
}
is LoginNavigation.OnResetPasswordSuccessDone -> supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
}
}
.disposeOnDestroy()

View file

@ -19,6 +19,7 @@ package im.vector.riotx.features.login
import android.os.Bundle
import android.view.View
import androidx.core.view.isVisible
import butterknife.OnClick
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
@ -50,7 +51,7 @@ class LoginFragment @Inject constructor(
super.onViewCreated(view, savedInstanceState)
setupUi()
setupLoginButton()
setupSubmitButton()
setupPasswordReveal()
}
@ -74,19 +75,17 @@ class LoginFragment @Inject constructor(
loginServerIcon.setImageResource(R.drawable.ic_logo_modular)
// TODO
loginTitle.text = getString(R.string.login_connect_to, "TODO")
// TODO Remove https://
loginNotice.text = loginViewModel.getHomeServerUrl()
loginNotice.text = loginViewModel.getHomeServerUrlSimple()
}
ServerType.Other -> {
loginServerIcon.isVisible = false
loginTitle.text = getString(R.string.login_server_other_title)
// TODO Remove https://
loginNotice.text = loginViewModel.getHomeServerUrl()
loginNotice.text = loginViewModel.getHomeServerUrlSimple()
}
}
}
private fun setupLoginButton() {
private fun setupSubmitButton() {
Observable
.combineLatest(
loginField.textChanges().map { it.trim().isNotEmpty() },
@ -105,6 +104,11 @@ class LoginFragment @Inject constructor(
loginSubmit.setOnClickListener { authenticate() }
}
@OnClick(R.id.forgetPasswordButton)
fun forgetPasswordClicked() {
loginSharedActionViewModel.post(LoginNavigation.OnForgetPasswordClicked)
}
private fun setupPasswordReveal() {
passwordShown = false

View file

@ -24,5 +24,9 @@ sealed class LoginNavigation : VectorSharedAction {
object OnServerSelectionDone : LoginNavigation()
object OnLoginFlowRetrieved : LoginNavigation()
object OnSignModeSelected : LoginNavigation()
object OnForgetPasswordClicked : LoginNavigation()
object OnResetPasswordSuccess : LoginNavigation()
object OnResetPasswordSuccessDone : LoginNavigation()
data class OnWebLoginError(val errorCode: Int, val description: String, val failingUrl: String) : LoginNavigation()
}

View file

@ -0,0 +1,136 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.login
import android.os.Bundle
import android.view.View
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.withState
import com.jakewharton.rxbinding3.widget.textChanges
import im.vector.riotx.R
import im.vector.riotx.core.error.ErrorFormatter
import im.vector.riotx.core.extensions.showPassword
import io.reactivex.Observable
import io.reactivex.functions.BiFunction
import io.reactivex.rxkotlin.subscribeBy
import kotlinx.android.synthetic.main.fragment_login.*
import kotlinx.android.synthetic.main.fragment_login.passwordField
import kotlinx.android.synthetic.main.fragment_login.passwordFieldTil
import kotlinx.android.synthetic.main.fragment_login.passwordReveal
import kotlinx.android.synthetic.main.fragment_login_reset_password.*
import javax.inject.Inject
/**
* In this screen, the user is asked for email and new password to reset his password
*/
class LoginResetPasswordFragment @Inject constructor(
private val errorFormatter: ErrorFormatter
) : AbstractLoginFragment() {
private var passwordShown = false
override fun getLayoutResId() = R.layout.fragment_login_reset_password
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupUi()
setupSubmitButton()
setupPasswordReveal()
}
private fun setupUi() {
resetPasswordTitle.text = getString(R.string.login_reset_password_on, loginViewModel.getHomeServerUrlSimple())
}
private fun setupSubmitButton() {
Observable
.combineLatest(
resetPasswordEmail.textChanges().map { it.trim().isNotEmpty() },
passwordField.textChanges().map { it.trim().isNotEmpty() },
BiFunction<Boolean, Boolean, Boolean> { isEmailNotEmpty, isPasswordNotEmpty ->
isEmailNotEmpty && isPasswordNotEmpty
}
)
.subscribeBy {
resetPasswordEmail.error = null
passwordFieldTil.error = null
loginSubmit.isEnabled = it
}
.disposeOnDestroy()
resetPasswordSubmit.setOnClickListener { submit() }
}
private fun submit() {
val email = resetPasswordEmail.text?.trim().toString()
val password = passwordField.text?.trim().toString()
// TODO Add static check?
loginViewModel.handle(LoginAction.ResetPassword(email, password))
}
private fun setupPasswordReveal() {
passwordShown = false
passwordReveal.setOnClickListener {
passwordShown = !passwordShown
renderPasswordField()
}
renderPasswordField()
}
private fun renderPasswordField() {
passwordField.showPassword(passwordShown)
if (passwordShown) {
passwordReveal.setImageResource(R.drawable.ic_eye_closed_black)
passwordReveal.contentDescription = getString(R.string.a11y_hide_password)
} else {
passwordReveal.setImageResource(R.drawable.ic_eye_black)
passwordReveal.contentDescription = getString(R.string.a11y_show_password)
}
}
override fun resetViewModel() {
loginViewModel.handle(LoginAction.ResetResetPassword)
}
override fun invalidate() = withState(loginViewModel) { state ->
when (state.asyncResetPassword) {
is Loading -> {
// Ensure new password is hidden
passwordShown = false
renderPasswordField()
}
is Fail -> {
// TODO This does not work, we want the error to be on without text. Fix that
resetPasswordEmailTil.error = ""
// TODO Handle error text properly
passwordFieldTil.error = errorFormatter.toHumanReadable(state.asyncResetPassword.error)
}
is Success -> {
loginSharedActionViewModel.post(LoginNavigation.OnResetPasswordSuccess)
}
}
}
}

View file

@ -0,0 +1,51 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.login
import android.os.Bundle
import android.view.View
import butterknife.OnClick
import im.vector.riotx.R
import kotlinx.android.synthetic.main.fragment_login_reset_password_success.*
import javax.inject.Inject
/**
* In this screen, the user is asked for email and new password to reset his password
*/
class LoginResetPasswordSuccessFragment @Inject constructor() : AbstractLoginFragment() {
override fun getLayoutResId() = R.layout.fragment_login_reset_password_success
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupUi()
}
private fun setupUi() {
resetPasswordSuccessNotice.text = getString(R.string.login_reset_password_success_notice, loginViewModel.resetPasswordEmail)
}
@OnClick(R.id.resetPasswordSuccessSubmit)
fun submit() {
loginSharedActionViewModel.post(LoginNavigation.OnResetPasswordSuccessDone)
}
override fun resetViewModel() {
loginViewModel.handle(LoginAction.ResetResetPassword)
}
}

View file

@ -57,9 +57,10 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
var serverType: ServerType = ServerType.MatrixOrg
private set
var signMode: SignMode = SignMode.Unknown
private set
var resetPasswordEmail: String? = null
private set
private var loginConfig: LoginConfig? = null
@ -74,6 +75,7 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
is LoginAction.UpdateHomeServer -> handleUpdateHomeserver(action)
is LoginAction.Login -> handleLogin(action)
is LoginAction.WebLoginSuccess -> handleWebLoginSuccess(action)
is LoginAction.ResetPassword -> handleResetPassword(action)
is LoginAction.ResetAction -> handleResetAction(action)
}
}
@ -109,6 +111,14 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
)
}
}
LoginAction.ResetResetPassword -> {
resetPasswordEmail = null
setState {
copy(
asyncResetPassword = Uninitialized
)
}
}
}
}
@ -124,6 +134,45 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
loginConfig = action.loginConfig
}
private fun handleResetPassword(action: LoginAction.ResetPassword) {
val homeServerConnectionConfigFinal = homeServerConnectionConfig
if (homeServerConnectionConfigFinal == null) {
setState {
copy(
asyncResetPassword = Fail(Throwable("Bad configuration"))
)
}
} else {
resetPasswordEmail = action.email
setState {
copy(
asyncResetPassword = Loading()
)
}
currentTask = authenticator.resetPassword(homeServerConnectionConfigFinal, action.email, action.newPassword, object : MatrixCallback<Unit> {
override fun onSuccess(data: Unit) {
setState {
copy(
asyncResetPassword = Success(data)
)
}
}
override fun onFailure(failure: Throwable) {
// TODO Handled JobCancellationException
setState {
copy(
asyncResetPassword = Fail(failure)
)
}
}
})
}
}
private fun handleLogin(action: LoginAction.Login) {
val homeServerConnectionConfigFinal = homeServerConnectionConfig
@ -259,4 +308,8 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
fun getHomeServerUrl(): String {
return homeServerConnectionConfig?.homeServerUri?.toString() ?: ""
}
fun getHomeServerUrlSimple(): String {
return getHomeServerUrl().substringAfter("://")
}
}

View file

@ -16,17 +16,21 @@
package im.vector.riotx.features.login
import com.airbnb.mvrx.*
data class LoginViewState(
val asyncLoginAction: Async<Unit> = Uninitialized,
val asyncHomeServerLoginFlowRequest: Async<LoginMode> = Uninitialized
val asyncHomeServerLoginFlowRequest: Async<LoginMode> = Uninitialized,
val asyncResetPassword: Async<Unit> = Uninitialized
) : MvRxState {
fun isLoading(): Boolean {
// TODO Add other async here
return asyncLoginAction is Loading
|| asyncHomeServerLoginFlowRequest is Loading
|| asyncResetPassword is Loading
}
fun isUserLogged(): Boolean {

View file

@ -0,0 +1,118 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<ImageView
android:id="@+id/logoImageView"
style="@style/LoginTopIcon"
android:layout_gravity="center_horizontal" />
<TextView
android:id="@+id/resetPasswordTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="84dp"
android:textAppearance="@style/TextAppearance.Vector.Login.Title"
tools:text="@string/login_reset_password_on" />
<TextView
android:id="@+id/loginNotice"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/layout_vertical_margin"
android:gravity="start"
android:text="@string/login_reset_password_notice"
android:textAppearance="@style/TextAppearance.Vector.Login.Text.Small" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/resetPasswordEmailTil"
style="@style/VectorTextInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:hint="@string/login_reset_password_email_hint"
app:errorEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/resetPasswordEmail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textEmailAddress"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<FrameLayout
android:id="@+id/passwordContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/passwordFieldTil"
style="@style/VectorTextInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_reset_password_password_hint"
app:errorEnabled="true"
app:errorIconDrawable="@null">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/passwordField"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ems="10"
android:inputType="textPassword"
android:maxLines="1"
android:paddingEnd="48dp"
android:paddingRight="48dp"
tools:ignore="RtlSymmetry" />
</com.google.android.material.textfield.TextInputLayout>
<ImageView
android:id="@+id/passwordReveal"
android:layout_width="@dimen/layout_touch_size"
android:layout_height="@dimen/layout_touch_size"
android:layout_gravity="end"
android:layout_marginTop="8dp"
android:background="?attr/selectableItemBackground"
android:scaleType="center"
android:src="@drawable/ic_eye_black"
android:tint="?attr/colorAccent"
tools:contentDescription="@string/a11y_show_password" />
</FrameLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/resetPasswordSubmit"
style="@style/Style.Vector.Login.Button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/login_reset_password_submit"
tools:ignore="RelativeOverlap" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<ImageView
android:id="@+id/logoImageView"
style="@style/LoginTopIcon"
android:layout_gravity="center_horizontal" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="84dp"
android:text="@string/login_reset_password_success_title"
android:textAppearance="@style/TextAppearance.Vector.Login.Title" />
<TextView
android:id="@+id/resetPasswordSuccessNotice"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/layout_vertical_margin"
android:textAppearance="@style/TextAppearance.Vector.Login.Text.Small"
tools:text="@string/login_reset_password_success_notice" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/layout_vertical_margin"
android:text="@string/login_reset_password_success_notice_2"
android:textAppearance="@style/TextAppearance.Vector.Login.Text.Small" />
<com.google.android.material.button.MaterialButton
android:id="@+id/resetPasswordSuccessSubmit"
style="@style/Style.Vector.Login.Button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/layout_vertical_margin"
android:text="@string/login_reset_password_success_submit" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -38,9 +38,11 @@
<string name="login_server_other_text">Custom &amp; advanced settings</string>
<string name="login_continue">Continue</string>
<!-- Replaced string is the homeserver url -->
<string name="login_connect_to">Connect to %1$s</string>
<string name="login_connect_to_modular">Connect to Modular</string>
<string name="login_connect_to_a_custom_server">Connect to a custom server</string>
<!-- Replaced string is the homeserver url -->
<string name="login_signin_to">Sign in to %1$s</string>
<string name="login_signup">Sign Up</string>
<string name="login_signin">Sign In</string>
@ -55,4 +57,16 @@
<string name="login_sso_error_message">An error occurred when loading the page: %1$s (%2$d)</string>
<string name="login_mode_not_supported">The application is not able to signin to this homeserver. The homeserver supports the following signin type(s): %1$s.\n\nDo you want to signin using a web client?</string>
<!-- Replaced string is the homeserver url -->
<string name="login_reset_password_on">Reset password on %1$s</string>
<string name="login_reset_password_notice">A verification email will be sent to your inbox to confirm setting your new password.</string>
<string name="login_reset_password_submit">Next</string>
<string name="login_reset_password_email_hint">Email</string>
<string name="login_reset_password_password_hint">New password</string>
<string name="login_reset_password_success_title">Check your inbox</string>
<!-- Replaced string is an email -->
<string name="login_reset_password_success_notice">A verification email was sent to %1$s.</string>
<string name="login_reset_password_success_notice_2">Tap on the link to confirm your new password.</string>
<string name="login_reset_password_success_submit">Back to Sign In</string>
</resources>