android auth: add step-based email/register/2fa flow and startup route
This commit is contained in:
@@ -765,3 +765,18 @@
|
|||||||
- group invites.
|
- group invites.
|
||||||
- Removed direct `block by user id` controls from Settings UI as requested.
|
- Removed direct `block by user id` controls from Settings UI as requested.
|
||||||
- Removed extra bottom Settings actions (`Profile` row and `Back to chats` button) and kept categorized section layout.
|
- Removed extra bottom Settings actions (`Profile` row and `Back to chats` button) and kept categorized section layout.
|
||||||
|
|
||||||
|
### Step 113 - Auth flow redesign (email -> password/register -> 2FA) + startup no-flicker
|
||||||
|
- Added step-based auth domain/use-cases for:
|
||||||
|
- `GET /api/v1/auth/check-email`
|
||||||
|
- `POST /api/v1/auth/register`
|
||||||
|
- login with optional `otp_code` / `recovery_code`.
|
||||||
|
- Updated Android login UI to multi-step flow:
|
||||||
|
- step 1: email input,
|
||||||
|
- step 2: password for existing account or register form (`name`, `username`, `password`) for new account,
|
||||||
|
- step 3: 2FA OTP/recovery code when backend requires it.
|
||||||
|
- Improved login error mapping for 2FA-required responses, so app switches to OTP step instead of generic invalid-password message.
|
||||||
|
- Removed auth screen flash on startup:
|
||||||
|
- introduced dedicated `startup` route with session-check loader,
|
||||||
|
- delayed auth/chats navigation until session check is finished.
|
||||||
|
- Added safe fallback in `MainActivity` theme bootstrap to prevent crash if `ThemeRepository` injection is unexpectedly unavailable during startup.
|
||||||
|
|||||||
@@ -34,7 +34,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
val savedThemeMode = runBlocking { themeRepository.getThemeMode() }
|
val savedThemeMode = if (this::themeRepository.isInitialized) {
|
||||||
|
runBlocking { themeRepository.getThemeMode() }
|
||||||
|
} else {
|
||||||
|
AppThemeMode.SYSTEM
|
||||||
|
}
|
||||||
AppCompatDelegate.setDefaultNightMode(
|
AppCompatDelegate.setDefaultNightMode(
|
||||||
when (savedThemeMode) {
|
when (savedThemeMode) {
|
||||||
AppThemeMode.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
|
AppThemeMode.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ import ru.daemonlord.messenger.core.token.StoredAccount
|
|||||||
import ru.daemonlord.messenger.core.token.TokenRepository
|
import ru.daemonlord.messenger.core.token.TokenRepository
|
||||||
import ru.daemonlord.messenger.data.auth.api.AuthApiService
|
import ru.daemonlord.messenger.data.auth.api.AuthApiService
|
||||||
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
|
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
|
||||||
|
import ru.daemonlord.messenger.data.auth.dto.CheckEmailStatusDto
|
||||||
import ru.daemonlord.messenger.data.auth.dto.AuthSessionDto
|
import ru.daemonlord.messenger.data.auth.dto.AuthSessionDto
|
||||||
import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto
|
import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto
|
||||||
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
|
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
|
||||||
|
import ru.daemonlord.messenger.data.auth.dto.RegisterRequestDto
|
||||||
import ru.daemonlord.messenger.data.common.ApiErrorMode
|
import ru.daemonlord.messenger.data.common.ApiErrorMode
|
||||||
import ru.daemonlord.messenger.data.common.toAppError
|
import ru.daemonlord.messenger.data.common.toAppError
|
||||||
import ru.daemonlord.messenger.di.IoDispatcher
|
import ru.daemonlord.messenger.di.IoDispatcher
|
||||||
|
import ru.daemonlord.messenger.domain.auth.model.AuthEmailStatus
|
||||||
import ru.daemonlord.messenger.domain.auth.model.AuthUser
|
import ru.daemonlord.messenger.domain.auth.model.AuthUser
|
||||||
import ru.daemonlord.messenger.domain.auth.model.AuthSession
|
import ru.daemonlord.messenger.domain.auth.model.AuthSession
|
||||||
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
||||||
@@ -30,12 +33,56 @@ class NetworkAuthRepository @Inject constructor(
|
|||||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||||
) : AuthRepository {
|
) : AuthRepository {
|
||||||
|
|
||||||
override suspend fun login(email: String, password: String): AppResult<AuthUser> = withContext(ioDispatcher) {
|
override suspend fun checkEmailStatus(email: String): AppResult<AuthEmailStatus> = withContext(ioDispatcher) {
|
||||||
|
val normalized = email.trim().lowercase()
|
||||||
|
if (normalized.isBlank()) return@withContext AppResult.Error(AppError.Server("Email is required"))
|
||||||
|
try {
|
||||||
|
AppResult.Success(authApiService.checkEmailStatus(normalized).toDomain())
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun register(
|
||||||
|
email: String,
|
||||||
|
name: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
|
val normalizedEmail = email.trim().lowercase()
|
||||||
|
val normalizedName = name.trim()
|
||||||
|
val normalizedUsername = username.trim().removePrefix("@")
|
||||||
|
if (normalizedEmail.isBlank() || normalizedName.isBlank() || normalizedUsername.isBlank() || password.isBlank()) {
|
||||||
|
return@withContext AppResult.Error(AppError.Server("Email, name, username and password are required"))
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
authApiService.register(
|
||||||
|
request = RegisterRequestDto(
|
||||||
|
email = normalizedEmail,
|
||||||
|
name = normalizedName,
|
||||||
|
username = normalizedUsername,
|
||||||
|
password = password,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AppResult.Success(Unit)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
AppResult.Error(error.toAppError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun login(
|
||||||
|
email: String,
|
||||||
|
password: String,
|
||||||
|
otpCode: String?,
|
||||||
|
recoveryCode: String?,
|
||||||
|
): AppResult<AuthUser> = withContext(ioDispatcher) {
|
||||||
try {
|
try {
|
||||||
val tokenResponse = authApiService.login(
|
val tokenResponse = authApiService.login(
|
||||||
request = LoginRequestDto(
|
request = LoginRequestDto(
|
||||||
email = email,
|
email = email,
|
||||||
password = password,
|
password = password,
|
||||||
|
otpCode = otpCode?.trim()?.ifBlank { null },
|
||||||
|
recoveryCode = recoveryCode?.trim()?.ifBlank { null },
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
tokenRepository.saveTokens(
|
tokenRepository.saveTokens(
|
||||||
@@ -183,4 +230,13 @@ class NetworkAuthRepository @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun CheckEmailStatusDto.toDomain(): AuthEmailStatus {
|
||||||
|
return AuthEmailStatus(
|
||||||
|
email = email,
|
||||||
|
registered = registered,
|
||||||
|
emailVerified = emailVerified,
|
||||||
|
twofaEnabled = twofaEnabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ru.daemonlord.messenger.data.common
|
|||||||
import retrofit2.HttpException
|
import retrofit2.HttpException
|
||||||
import ru.daemonlord.messenger.domain.common.AppError
|
import ru.daemonlord.messenger.domain.common.AppError
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
enum class ApiErrorMode {
|
enum class ApiErrorMode {
|
||||||
DEFAULT,
|
DEFAULT,
|
||||||
@@ -13,15 +14,24 @@ fun Throwable.toAppError(mode: ApiErrorMode = ApiErrorMode.DEFAULT): AppError {
|
|||||||
return when (this) {
|
return when (this) {
|
||||||
is IOException -> AppError.Network
|
is IOException -> AppError.Network
|
||||||
is HttpException -> when (mode) {
|
is HttpException -> when (mode) {
|
||||||
ApiErrorMode.LOGIN -> when (code()) {
|
ApiErrorMode.LOGIN -> {
|
||||||
400, 401, 403 -> AppError.InvalidCredentials
|
val detail = extractErrorDetail()
|
||||||
else -> AppError.Server(message = message())
|
when (code()) {
|
||||||
|
400, 401, 403 -> {
|
||||||
|
if (detail?.contains("2fa code required", ignoreCase = true) == true) {
|
||||||
|
AppError.Server(message = detail)
|
||||||
|
} else {
|
||||||
|
AppError.InvalidCredentials
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> AppError.Server(message = detail ?: message())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiErrorMode.DEFAULT -> if (code() == 401 || code() == 403) {
|
ApiErrorMode.DEFAULT -> if (code() == 401 || code() == 403) {
|
||||||
AppError.Unauthorized
|
AppError.Unauthorized
|
||||||
} else {
|
} else {
|
||||||
AppError.Server(message = message())
|
AppError.Server(message = extractErrorDetail() ?: message())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,3 +39,11 @@ fun Throwable.toAppError(mode: ApiErrorMode = ApiErrorMode.DEFAULT): AppError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun HttpException.extractErrorDetail(): String? {
|
||||||
|
return runCatching {
|
||||||
|
val body = response()?.errorBody()?.string()?.trim().orEmpty()
|
||||||
|
if (body.isBlank()) return@runCatching null
|
||||||
|
val json = JSONObject(body)
|
||||||
|
json.optString("detail").takeIf { it.isNotBlank() }
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package ru.daemonlord.messenger.domain.auth.model
|
||||||
|
|
||||||
|
data class AuthEmailStatus(
|
||||||
|
val email: String,
|
||||||
|
val registered: Boolean,
|
||||||
|
val emailVerified: Boolean,
|
||||||
|
val twofaEnabled: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
@@ -2,10 +2,18 @@ package ru.daemonlord.messenger.domain.auth.repository
|
|||||||
|
|
||||||
import ru.daemonlord.messenger.domain.auth.model.AuthUser
|
import ru.daemonlord.messenger.domain.auth.model.AuthUser
|
||||||
import ru.daemonlord.messenger.domain.auth.model.AuthSession
|
import ru.daemonlord.messenger.domain.auth.model.AuthSession
|
||||||
|
import ru.daemonlord.messenger.domain.auth.model.AuthEmailStatus
|
||||||
import ru.daemonlord.messenger.domain.common.AppResult
|
import ru.daemonlord.messenger.domain.common.AppResult
|
||||||
|
|
||||||
interface AuthRepository {
|
interface AuthRepository {
|
||||||
suspend fun login(email: String, password: String): AppResult<AuthUser>
|
suspend fun checkEmailStatus(email: String): AppResult<AuthEmailStatus>
|
||||||
|
suspend fun register(email: String, name: String, username: String, password: String): AppResult<Unit>
|
||||||
|
suspend fun login(
|
||||||
|
email: String,
|
||||||
|
password: String,
|
||||||
|
otpCode: String? = null,
|
||||||
|
recoveryCode: String? = null,
|
||||||
|
): AppResult<AuthUser>
|
||||||
suspend fun refreshTokens(): AppResult<Unit>
|
suspend fun refreshTokens(): AppResult<Unit>
|
||||||
suspend fun getMe(): AppResult<AuthUser>
|
suspend fun getMe(): AppResult<AuthUser>
|
||||||
suspend fun restoreSession(): AppResult<AuthUser>
|
suspend fun restoreSession(): AppResult<AuthUser>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package ru.daemonlord.messenger.domain.auth.usecase
|
||||||
|
|
||||||
|
import ru.daemonlord.messenger.domain.auth.model.AuthEmailStatus
|
||||||
|
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
||||||
|
import ru.daemonlord.messenger.domain.common.AppResult
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class CheckEmailStatusUseCase @Inject constructor(
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(email: String): AppResult<AuthEmailStatus> {
|
||||||
|
return authRepository.checkEmailStatus(email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -8,7 +8,17 @@ import javax.inject.Inject
|
|||||||
class LoginUseCase @Inject constructor(
|
class LoginUseCase @Inject constructor(
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
) {
|
) {
|
||||||
suspend operator fun invoke(email: String, password: String): AppResult<AuthUser> {
|
suspend operator fun invoke(
|
||||||
return authRepository.login(email = email, password = password)
|
email: String,
|
||||||
|
password: String,
|
||||||
|
otpCode: String? = null,
|
||||||
|
recoveryCode: String? = null,
|
||||||
|
): AppResult<AuthUser> {
|
||||||
|
return authRepository.login(
|
||||||
|
email = email,
|
||||||
|
password = password,
|
||||||
|
otpCode = otpCode,
|
||||||
|
recoveryCode = recoveryCode,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package ru.daemonlord.messenger.domain.auth.usecase
|
||||||
|
|
||||||
|
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
||||||
|
import ru.daemonlord.messenger.domain.common.AppResult
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class RegisterUseCase @Inject constructor(
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
email: String,
|
||||||
|
name: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
): AppResult<Unit> {
|
||||||
|
return authRepository.register(
|
||||||
|
email = email,
|
||||||
|
name = name,
|
||||||
|
username = username,
|
||||||
|
password = password,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,10 +1,25 @@
|
|||||||
package ru.daemonlord.messenger.ui.auth
|
package ru.daemonlord.messenger.ui.auth
|
||||||
|
|
||||||
|
enum class AuthStep {
|
||||||
|
EMAIL,
|
||||||
|
PASSWORD,
|
||||||
|
REGISTER,
|
||||||
|
OTP,
|
||||||
|
}
|
||||||
|
|
||||||
data class AuthUiState(
|
data class AuthUiState(
|
||||||
|
val step: AuthStep = AuthStep.EMAIL,
|
||||||
val email: String = "",
|
val email: String = "",
|
||||||
|
val name: String = "",
|
||||||
|
val username: String = "",
|
||||||
val password: String = "",
|
val password: String = "",
|
||||||
|
val otpCode: String = "",
|
||||||
|
val recoveryCode: String = "",
|
||||||
|
val useRecoveryCode: Boolean = false,
|
||||||
val isCheckingSession: Boolean = true,
|
val isCheckingSession: Boolean = true,
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val isAuthenticated: Boolean = false,
|
val isAuthenticated: Boolean = false,
|
||||||
|
val successMessage: String? = null,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.daemonlord.messenger.domain.auth.usecase.CheckEmailStatusUseCase
|
||||||
import ru.daemonlord.messenger.domain.auth.usecase.LoginUseCase
|
import ru.daemonlord.messenger.domain.auth.usecase.LoginUseCase
|
||||||
import ru.daemonlord.messenger.domain.auth.usecase.LogoutUseCase
|
import ru.daemonlord.messenger.domain.auth.usecase.LogoutUseCase
|
||||||
|
import ru.daemonlord.messenger.domain.auth.usecase.RegisterUseCase
|
||||||
import ru.daemonlord.messenger.domain.auth.usecase.RestoreSessionUseCase
|
import ru.daemonlord.messenger.domain.auth.usecase.RestoreSessionUseCase
|
||||||
import ru.daemonlord.messenger.domain.common.AppError
|
import ru.daemonlord.messenger.domain.common.AppError
|
||||||
import ru.daemonlord.messenger.domain.common.AppResult
|
import ru.daemonlord.messenger.domain.common.AppResult
|
||||||
@@ -18,6 +20,8 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AuthViewModel @Inject constructor(
|
class AuthViewModel @Inject constructor(
|
||||||
private val restoreSessionUseCase: RestoreSessionUseCase,
|
private val restoreSessionUseCase: RestoreSessionUseCase,
|
||||||
|
private val checkEmailStatusUseCase: CheckEmailStatusUseCase,
|
||||||
|
private val registerUseCase: RegisterUseCase,
|
||||||
private val loginUseCase: LoginUseCase,
|
private val loginUseCase: LoginUseCase,
|
||||||
private val logoutUseCase: LogoutUseCase,
|
private val logoutUseCase: LogoutUseCase,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@@ -30,23 +34,220 @@ class AuthViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onEmailChanged(value: String) {
|
fun onEmailChanged(value: String) {
|
||||||
_uiState.update { it.copy(email = value, errorMessage = null) }
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
email = value,
|
||||||
|
errorMessage = null,
|
||||||
|
successMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onNameChanged(value: String) {
|
||||||
|
_uiState.update { it.copy(name = value, errorMessage = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onUsernameChanged(value: String) {
|
||||||
|
_uiState.update { it.copy(username = value.replace("@", ""), errorMessage = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onPasswordChanged(value: String) {
|
fun onPasswordChanged(value: String) {
|
||||||
_uiState.update { it.copy(password = value, errorMessage = null) }
|
_uiState.update { it.copy(password = value, errorMessage = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun login() {
|
fun onOtpCodeChanged(value: String) {
|
||||||
val state = uiState.value
|
_uiState.update { it.copy(otpCode = value.filter(Char::isDigit).take(8), errorMessage = null) }
|
||||||
if (state.email.isBlank() || state.password.isBlank()) {
|
}
|
||||||
_uiState.update { it.copy(errorMessage = "Email and password are required.") }
|
|
||||||
|
fun onRecoveryCodeChanged(value: String) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
recoveryCode = value.uppercase().filter { ch -> ch.isLetterOrDigit() || ch == '-' }.take(32),
|
||||||
|
errorMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleRecoveryCodeMode() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
useRecoveryCode = !it.useRecoveryCode,
|
||||||
|
errorMessage = null,
|
||||||
|
successMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun backToEmailStep() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
step = AuthStep.EMAIL,
|
||||||
|
password = "",
|
||||||
|
otpCode = "",
|
||||||
|
recoveryCode = "",
|
||||||
|
useRecoveryCode = false,
|
||||||
|
errorMessage = null,
|
||||||
|
successMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun continueWithEmail() {
|
||||||
|
val email = uiState.value.email.trim().lowercase()
|
||||||
|
if (email.isBlank()) {
|
||||||
|
_uiState.update { it.copy(errorMessage = "Enter email.") }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = true,
|
||||||
|
errorMessage = null,
|
||||||
|
successMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
when (val result = checkEmailStatusUseCase(email)) {
|
||||||
|
is AppResult.Success -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
email = result.data.email,
|
||||||
|
isLoading = false,
|
||||||
|
step = if (result.data.registered) AuthStep.PASSWORD else AuthStep.REGISTER,
|
||||||
|
errorMessage = null,
|
||||||
|
successMessage = if (result.data.registered) null else "This email is not registered. Complete sign up.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is AppResult.Error -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = result.reason.toUiMessage(),
|
||||||
|
successMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun submitAuthStep() {
|
||||||
|
when (uiState.value.step) {
|
||||||
|
AuthStep.EMAIL -> continueWithEmail()
|
||||||
|
AuthStep.REGISTER -> register()
|
||||||
|
AuthStep.PASSWORD -> loginWithoutOtp()
|
||||||
|
AuthStep.OTP -> loginWithOtp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun register() {
|
||||||
|
val state = uiState.value
|
||||||
|
if (state.name.isBlank() || state.username.isBlank() || state.password.isBlank()) {
|
||||||
|
_uiState.update { it.copy(errorMessage = "Name, username and password are required.") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true, errorMessage = null, successMessage = null) }
|
||||||
|
when (
|
||||||
|
val result = registerUseCase(
|
||||||
|
email = state.email.trim().lowercase(),
|
||||||
|
name = state.name.trim(),
|
||||||
|
username = state.username.trim().removePrefix("@"),
|
||||||
|
password = state.password,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
is AppResult.Success -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
step = AuthStep.PASSWORD,
|
||||||
|
successMessage = "Account created. Use password to sign in.",
|
||||||
|
errorMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is AppResult.Error -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = result.reason.toUiMessage(),
|
||||||
|
successMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loginWithoutOtp() {
|
||||||
|
val state = uiState.value
|
||||||
|
if (state.password.isBlank()) {
|
||||||
|
_uiState.update { it.copy(errorMessage = "Password is required.") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true, errorMessage = null, successMessage = null) }
|
||||||
when (val result = loginUseCase(state.email.trim(), state.password)) {
|
when (val result = loginUseCase(state.email.trim(), state.password)) {
|
||||||
|
is AppResult.Success -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isAuthenticated = true,
|
||||||
|
errorMessage = null,
|
||||||
|
successMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is AppResult.Error -> {
|
||||||
|
val isOtpRequired = (result.reason as? AppError.Server)
|
||||||
|
?.message
|
||||||
|
?.contains("2fa code required", ignoreCase = true) == true
|
||||||
|
if (isOtpRequired) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
step = AuthStep.OTP,
|
||||||
|
errorMessage = null,
|
||||||
|
successMessage = "Enter 2FA code or recovery code.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isAuthenticated = false,
|
||||||
|
errorMessage = result.reason.toUiMessage(),
|
||||||
|
successMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loginWithOtp() {
|
||||||
|
val state = uiState.value
|
||||||
|
val otpCode = state.otpCode.trim().ifBlank { null }
|
||||||
|
val recoveryCode = state.recoveryCode.trim().ifBlank { null }
|
||||||
|
if (!state.useRecoveryCode && otpCode.isNullOrBlank()) {
|
||||||
|
_uiState.update { it.copy(errorMessage = "Enter 2FA code.") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (state.useRecoveryCode && recoveryCode.isNullOrBlank()) {
|
||||||
|
_uiState.update { it.copy(errorMessage = "Enter recovery code.") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true, errorMessage = null, successMessage = null) }
|
||||||
|
when (
|
||||||
|
val result = loginUseCase(
|
||||||
|
email = state.email.trim(),
|
||||||
|
password = state.password,
|
||||||
|
otpCode = if (state.useRecoveryCode) null else otpCode,
|
||||||
|
recoveryCode = if (state.useRecoveryCode) recoveryCode else null,
|
||||||
|
)
|
||||||
|
) {
|
||||||
is AppResult.Success -> {
|
is AppResult.Success -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -56,7 +257,6 @@ class AuthViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is AppResult.Error -> {
|
is AppResult.Error -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -76,11 +276,18 @@ class AuthViewModel @Inject constructor(
|
|||||||
runCatching { logoutUseCase() }
|
runCatching { logoutUseCase() }
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
step = AuthStep.EMAIL,
|
||||||
email = "",
|
email = "",
|
||||||
|
name = "",
|
||||||
|
username = "",
|
||||||
password = "",
|
password = "",
|
||||||
|
otpCode = "",
|
||||||
|
recoveryCode = "",
|
||||||
|
useRecoveryCode = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isAuthenticated = false,
|
isAuthenticated = false,
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
|
successMessage = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,7 +310,6 @@ class AuthViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is AppResult.Error -> {
|
is AppResult.Error -> {
|
||||||
val keepAuthenticatedOffline = result.reason is AppError.Network
|
val keepAuthenticatedOffline = result.reason is AppError.Network
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
@@ -123,7 +329,7 @@ class AuthViewModel @Inject constructor(
|
|||||||
AppError.InvalidCredentials -> "Invalid email or password."
|
AppError.InvalidCredentials -> "Invalid email or password."
|
||||||
AppError.Network -> "Network error. Check your connection."
|
AppError.Network -> "Network error. Check your connection."
|
||||||
AppError.Unauthorized -> "Session expired. Please sign in again."
|
AppError.Unauthorized -> "Session expired. Please sign in again."
|
||||||
is AppError.Server -> "Server error. Please try again."
|
is AppError.Server -> message ?: "Server error. Please try again."
|
||||||
is AppError.Unknown -> "Unknown error. Please try again."
|
is AppError.Unknown -> "Unknown error. Please try again."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,12 +27,21 @@ import androidx.compose.ui.unit.dp
|
|||||||
fun LoginScreen(
|
fun LoginScreen(
|
||||||
state: AuthUiState,
|
state: AuthUiState,
|
||||||
onEmailChanged: (String) -> Unit,
|
onEmailChanged: (String) -> Unit,
|
||||||
|
onNameChanged: (String) -> Unit,
|
||||||
|
onUsernameChanged: (String) -> Unit,
|
||||||
onPasswordChanged: (String) -> Unit,
|
onPasswordChanged: (String) -> Unit,
|
||||||
onLoginClick: () -> Unit,
|
onOtpCodeChanged: (String) -> Unit,
|
||||||
|
onRecoveryCodeChanged: (String) -> Unit,
|
||||||
|
onToggleRecoveryCodeMode: () -> Unit,
|
||||||
|
onContinueEmail: () -> Unit,
|
||||||
|
onSubmitStep: () -> Unit,
|
||||||
|
onBackToEmail: () -> Unit,
|
||||||
onOpenVerifyEmail: () -> Unit,
|
onOpenVerifyEmail: () -> Unit,
|
||||||
onOpenResetPassword: () -> Unit,
|
onOpenResetPassword: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
|
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
|
||||||
|
val isBusy = state.isLoading
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -47,71 +56,194 @@ fun LoginScreen(
|
|||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Text(
|
|
||||||
text = "Messenger Login",
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
modifier = Modifier.padding(bottom = 24.dp),
|
|
||||||
)
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.email,
|
|
||||||
onValueChange = onEmailChanged,
|
|
||||||
label = { Text(text = "Email") },
|
|
||||||
singleLine = true,
|
|
||||||
enabled = !state.isLoading,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(bottom = 12.dp),
|
|
||||||
)
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.password,
|
|
||||||
onValueChange = onPasswordChanged,
|
|
||||||
label = { Text(text = "Password") },
|
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
|
||||||
singleLine = true,
|
|
||||||
enabled = !state.isLoading,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(bottom = 16.dp),
|
|
||||||
)
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = onLoginClick,
|
|
||||||
enabled = !state.isLoading,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
if (state.isLoading) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
modifier = Modifier.padding(2.dp),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(text = "Login")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.errorMessage.isNullOrBlank()) {
|
|
||||||
Text(
|
Text(
|
||||||
text = state.errorMessage,
|
text = "Messenger Login",
|
||||||
color = MaterialTheme.colorScheme.error,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
modifier = Modifier.padding(bottom = 14.dp),
|
||||||
modifier = Modifier.padding(top = 12.dp),
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
TextButton(
|
val subtitle = when (state.step) {
|
||||||
onClick = onOpenVerifyEmail,
|
AuthStep.EMAIL -> "Enter your email to continue"
|
||||||
enabled = !state.isLoading,
|
AuthStep.PASSWORD -> "Enter password for ${state.email}"
|
||||||
modifier = Modifier.padding(top = 8.dp),
|
AuthStep.REGISTER -> "Create account for ${state.email}"
|
||||||
) {
|
AuthStep.OTP -> "Two-factor authentication is enabled"
|
||||||
Text(text = "Verify email by token")
|
}
|
||||||
}
|
Text(
|
||||||
TextButton(
|
text = subtitle,
|
||||||
onClick = onOpenResetPassword,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
enabled = !state.isLoading,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
) {
|
modifier = Modifier.padding(bottom = 18.dp),
|
||||||
Text(text = "Forgot password")
|
)
|
||||||
}
|
|
||||||
|
when (state.step) {
|
||||||
|
AuthStep.EMAIL -> {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.email,
|
||||||
|
onValueChange = onEmailChanged,
|
||||||
|
label = { Text(text = "Email") },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !isBusy,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = onContinueEmail,
|
||||||
|
enabled = !isBusy,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
if (isBusy) {
|
||||||
|
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.padding(2.dp))
|
||||||
|
} else {
|
||||||
|
Text("Continue")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthStep.PASSWORD, AuthStep.REGISTER, AuthStep.OTP -> {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.email,
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text(text = "Email") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
onClick = onBackToEmail,
|
||||||
|
enabled = !isBusy,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Start)
|
||||||
|
.padding(bottom = 6.dp),
|
||||||
|
) {
|
||||||
|
Text("Change email")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.step == AuthStep.REGISTER) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.name,
|
||||||
|
onValueChange = onNameChanged,
|
||||||
|
label = { Text(text = "Name") },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !isBusy,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 10.dp),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.username,
|
||||||
|
onValueChange = onUsernameChanged,
|
||||||
|
label = { Text(text = "Username") },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !isBusy,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.step != AuthStep.OTP) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.password,
|
||||||
|
onValueChange = onPasswordChanged,
|
||||||
|
label = { Text(text = "Password") },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !isBusy,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.step == AuthStep.OTP) {
|
||||||
|
TextButton(
|
||||||
|
onClick = onToggleRecoveryCodeMode,
|
||||||
|
enabled = !isBusy,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Start)
|
||||||
|
.padding(bottom = 4.dp),
|
||||||
|
) {
|
||||||
|
Text(if (state.useRecoveryCode) "Use OTP code" else "Use recovery code")
|
||||||
|
}
|
||||||
|
if (state.useRecoveryCode) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.recoveryCode,
|
||||||
|
onValueChange = onRecoveryCodeChanged,
|
||||||
|
label = { Text(text = "Recovery code") },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !isBusy,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 10.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.otpCode,
|
||||||
|
onValueChange = onOtpCodeChanged,
|
||||||
|
label = { Text(text = "2FA code") },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !isBusy,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onSubmitStep,
|
||||||
|
enabled = !isBusy,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
if (isBusy) {
|
||||||
|
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.padding(2.dp))
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
when (state.step) {
|
||||||
|
AuthStep.PASSWORD -> "Sign in"
|
||||||
|
AuthStep.REGISTER -> "Create account"
|
||||||
|
AuthStep.OTP -> "Confirm 2FA"
|
||||||
|
AuthStep.EMAIL -> "Continue"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.errorMessage.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = state.errorMessage,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!state.successMessage.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = state.successMessage,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = onOpenVerifyEmail,
|
||||||
|
enabled = !isBusy,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
) {
|
||||||
|
Text(text = "Verify email by token")
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = onOpenResetPassword,
|
||||||
|
enabled = !isBusy,
|
||||||
|
) {
|
||||||
|
Text(text = "Forgot password")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import ru.daemonlord.messenger.ui.profile.ProfileRoute
|
|||||||
import ru.daemonlord.messenger.ui.settings.SettingsRoute
|
import ru.daemonlord.messenger.ui.settings.SettingsRoute
|
||||||
|
|
||||||
private object Routes {
|
private object Routes {
|
||||||
|
const val Startup = "startup"
|
||||||
const val AuthGraph = "auth_graph"
|
const val AuthGraph = "auth_graph"
|
||||||
const val Login = "login"
|
const val Login = "login"
|
||||||
const val VerifyEmail = "verify_email"
|
const val VerifyEmail = "verify_email"
|
||||||
@@ -167,25 +168,32 @@ fun MessengerNavHost(
|
|||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = Routes.AuthGraph,
|
startDestination = Routes.Startup,
|
||||||
) {
|
) {
|
||||||
|
composable(route = Routes.Startup) {
|
||||||
|
SessionCheckingScreen()
|
||||||
|
}
|
||||||
|
|
||||||
navigation(
|
navigation(
|
||||||
route = Routes.AuthGraph,
|
route = Routes.AuthGraph,
|
||||||
startDestination = Routes.Login,
|
startDestination = Routes.Login,
|
||||||
) {
|
) {
|
||||||
composable(route = Routes.Login) {
|
composable(route = Routes.Login) {
|
||||||
if (uiState.isCheckingSession) {
|
LoginScreen(
|
||||||
SessionCheckingScreen()
|
state = uiState,
|
||||||
} else {
|
onEmailChanged = viewModel::onEmailChanged,
|
||||||
LoginScreen(
|
onNameChanged = viewModel::onNameChanged,
|
||||||
state = uiState,
|
onUsernameChanged = viewModel::onUsernameChanged,
|
||||||
onEmailChanged = viewModel::onEmailChanged,
|
onPasswordChanged = viewModel::onPasswordChanged,
|
||||||
onPasswordChanged = viewModel::onPasswordChanged,
|
onOtpCodeChanged = viewModel::onOtpCodeChanged,
|
||||||
onLoginClick = viewModel::login,
|
onRecoveryCodeChanged = viewModel::onRecoveryCodeChanged,
|
||||||
onOpenVerifyEmail = { navController.navigate(Routes.VerifyEmail) },
|
onToggleRecoveryCodeMode = viewModel::toggleRecoveryCodeMode,
|
||||||
onOpenResetPassword = { navController.navigate(Routes.ResetPassword) },
|
onContinueEmail = viewModel::continueWithEmail,
|
||||||
)
|
onSubmitStep = viewModel::submitAuthStep,
|
||||||
}
|
onBackToEmail = viewModel::backToEmailStep,
|
||||||
|
onOpenVerifyEmail = { navController.navigate(Routes.VerifyEmail) },
|
||||||
|
onOpenResetPassword = { navController.navigate(Routes.ResetPassword) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user