Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
Expand All @@ -33,6 +34,7 @@ import de.tabmates.core.designsystem.buttons.TabMatesButtonStyle
import de.tabmates.core.designsystem.preview.PreviewThemes
import de.tabmates.core.designsystem.spacer.VerticalSpacer
import de.tabmates.core.designsystem.text.TabMatesInlineLinkText
import de.tabmates.core.designsystem.textfields.EmailInputTransformation
import de.tabmates.core.designsystem.textfields.TabMatesPasswordTextField
import de.tabmates.core.designsystem.textfields.TabMatesTextField
import de.tabmates.core.designsystem.theme.TabMatesTheme
Expand Down Expand Up @@ -145,7 +147,9 @@ private fun ReauthScreen(
enabled = !state.isEmailLocked,
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next,
capitalization = KeyboardCapitalization.None,
contentType = ContentType.EmailAddress,
inputTransformation = EmailInputTransformation,
)
TabMatesPasswordTextField(
modifier = Modifier.widthIn(max = 300.dp).fillMaxWidth(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import de.tabmates.core.presentation.util.UiText
import de.tabmates.core.presentation.util.toUiText
import de.tabmates.features.authentication.domain.AuthService
import de.tabmates.features.authentication.domain.EmailValidator
import de.tabmates.features.authentication.domain.normalizeEmail
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
Expand Down Expand Up @@ -55,7 +56,7 @@ class ReauthViewModel(
private val _state =
MutableStateFlow(
ReauthState(
emailTextFieldState = TextFieldState(staleSession?.email.orEmpty()),
emailTextFieldState = TextFieldState(staleSession?.email.orEmpty().normalizeEmail()),
isEmailLocked = staleSession?.email != null,
isGuest = staleSession?.userType == UserType.ANONYMOUS,
),
Expand All @@ -81,7 +82,7 @@ class ReauthViewModel(
snapshotFlow {
_state.value.emailTextFieldState.text
.toString()
.trim()
.normalizeEmail()
}.map { email -> EmailValidator.validate(email) }
.distinctUntilChanged()

Expand Down Expand Up @@ -126,7 +127,7 @@ class ReauthViewModel(
val email =
state.value.emailTextFieldState.text
.toString()
.trim()
.normalizeEmail()
val password =
state.value.passwordTextFieldState.text
.toString()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package de.tabmates.core.designsystem.textfields

import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.TextFieldBuffer

/**
* Folds an email field to lower case as it is typed or pasted, so what the user sees is what gets
* sent. Mail hosts treat addresses case-insensitively, and an account registered as "Max@Web.DE"
* must still be reachable when signing in as "max@web.de".
*
* Uses [Char.lowercaseChar] per character rather than [String.lowercase] on purpose: the mapping is
* one character to one, so the length is unchanged and the cursor stays where the user put it.
*/
object EmailInputTransformation : InputTransformation {
override fun TextFieldBuffer.transformInput() {
val text = asCharSequence().toString()
text.forEachIndexed { index, char ->
val lowercase = char.lowercaseChar()
if (char != lowercase) replace(index, index + 1, lowercase.toString())
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package de.tabmates.features.authentication.domain

/**
* The single form an email is compared and sent in. The email fields already lower-case what the
* user types, but text set programmatically — a prefilled field, platform autofill — bypasses that,
* so every read that reaches [EmailValidator] or the network goes through here.
*/
fun String.normalizeEmail(): String = trim().lowercase()
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package de.tabmates.features.authentication.domain

import kotlin.test.Test
import kotlin.test.assertEquals

class EmailNormalizationTest {
@Test
fun `lower cases the whole address`() {
assertEquals("max.mustermann@web.de", "Max.Mustermann@Web.DE".normalizeEmail())
}

@Test
fun `strips surrounding whitespace`() {
assertEquals("test@example.com", " test@example.com\n".normalizeEmail())
}

@Test
fun `trims and lower cases together`() {
assertEquals("max.mustermann@web.de", " Max.Mustermann@Web.DE ".normalizeEmail())
}

@Test
fun `leaves an already normalized address untouched`() {
assertEquals("test@example.com", "test@example.com".normalizeEmail())
}

@Test
fun `keeps a blank address empty so callers can still detect it`() {
assertEquals("", " ".normalizeEmail())
}

@Test
fun `normalized addresses pass validation`() {
assertEquals(true, EmailValidator.validate(" Max.Mustermann@Web.DE ".normalizeEmail()))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.tabmates.core.designsystem.buttons.TabMatesButton
import de.tabmates.core.designsystem.preview.PreviewThemes
import de.tabmates.core.designsystem.spacer.VerticalSpacer
import de.tabmates.core.designsystem.textfields.EmailInputTransformation
import de.tabmates.core.designsystem.textfields.TabMatesTextField
import de.tabmates.core.designsystem.theme.TabMatesTheme
import de.tabmates.core.designsystem.theme.extended
Expand Down Expand Up @@ -86,6 +88,8 @@ private fun ForgotPasswordScreen(
keyboardType = KeyboardType.Email,
singleLine = true,
imeAction = ImeAction.Done,
capitalization = KeyboardCapitalization.None,
inputTransformation = EmailInputTransformation,
onKeyboardAction = {
if (enabled) {
focusManager.clearFocus()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import de.tabmates.core.domain.util.onSuccess
import de.tabmates.core.presentation.util.toUiText
import de.tabmates.features.authentication.domain.AuthService
import de.tabmates.features.authentication.domain.EmailValidator
import de.tabmates.features.authentication.domain.normalizeEmail
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged
Expand All @@ -30,6 +31,7 @@ class ForgotPasswordViewModel(
snapshotFlow {
state.value.emailTextFieldState.text
.toString()
.normalizeEmail()
}.map { email -> EmailValidator.validate(email) }
.distinctUntilChanged()

Expand Down Expand Up @@ -64,6 +66,7 @@ class ForgotPasswordViewModel(
val email =
state.value.emailTextFieldState.text
.toString()
.normalizeEmail()
authService
.forgotPassword(email)
.onSuccess {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
Expand All @@ -32,6 +33,7 @@ import de.tabmates.core.designsystem.buttons.TabMatesButtonStyle
import de.tabmates.core.designsystem.preview.PreviewThemes
import de.tabmates.core.designsystem.spacer.VerticalSpacer
import de.tabmates.core.designsystem.text.TabMatesInlineLinkText
import de.tabmates.core.designsystem.textfields.EmailInputTransformation
import de.tabmates.core.designsystem.textfields.TabMatesPasswordTextField
import de.tabmates.core.designsystem.textfields.TabMatesTextField
import de.tabmates.core.designsystem.theme.TabMatesTheme
Expand Down Expand Up @@ -152,7 +154,9 @@ private fun LoginScreen(
placeholder = stringResource(Res.string.register_email_hint),
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next,
capitalization = KeyboardCapitalization.None,
contentType = ContentType.EmailAddress,
inputTransformation = EmailInputTransformation,
)
TabMatesPasswordTextField(
modifier = Modifier.widthIn(max = 300.dp).fillMaxWidth(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import de.tabmates.core.presentation.util.UiText
import de.tabmates.core.presentation.util.toUiText
import de.tabmates.features.authentication.domain.AuthService
import de.tabmates.features.authentication.domain.EmailValidator
import de.tabmates.features.authentication.domain.normalizeEmail
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
Expand Down Expand Up @@ -57,7 +58,7 @@ class LoginViewModel(
snapshotFlow {
_state.value.emailTextFieldState.text
.toString()
.trim()
.normalizeEmail()
}.map { email -> EmailValidator.validate(email) }
.distinctUntilChanged()

Expand Down Expand Up @@ -103,7 +104,7 @@ class LoginViewModel(
val email =
state.value.emailTextFieldState.text
.toString()
.trim()
.normalizeEmail()
val password =
state.value.passwordTextFieldState.text
.toString()
Expand Down Expand Up @@ -153,7 +154,7 @@ class LoginViewModel(
val email =
state.value.emailTextFieldState.text
.toString()
.trim()
.normalizeEmail()

authService
.resendVerificationEmail(email)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import de.tabmates.core.designsystem.buttons.TabMatesButtonStyle
import de.tabmates.core.designsystem.preview.PreviewThemes
import de.tabmates.core.designsystem.spacer.VerticalSpacer
import de.tabmates.core.designsystem.text.TabMatesInlineLinkText
import de.tabmates.core.designsystem.textfields.EmailInputTransformation
import de.tabmates.core.designsystem.textfields.TabMatesPasswordTextField
import de.tabmates.core.designsystem.textfields.TabMatesTextField
import de.tabmates.core.designsystem.theme.TabMatesTheme
Expand Down Expand Up @@ -149,8 +150,10 @@ private fun RegisterScreen(
singleLine = true,
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next,
onFocusChanged = onEmailFocusChanged,
capitalization = KeyboardCapitalization.None,
contentType = ContentType.EmailAddress,
inputTransformation = EmailInputTransformation,
onFocusChanged = onEmailFocusChanged,
)
state.emailError?.let {
VerticalSpacer(16.dp)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import de.tabmates.core.presentation.util.toUiText
import de.tabmates.features.authentication.domain.AuthService
import de.tabmates.features.authentication.domain.EmailValidator
import de.tabmates.features.authentication.domain.UsernameValidator
import de.tabmates.features.authentication.domain.normalizeEmail
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
Expand Down Expand Up @@ -127,6 +128,7 @@ class RegisterViewModel(
val email =
state.value.emailTextState.text
.toString()
.normalizeEmail()
if (email.isEmpty()) return
val error =
if (!EmailValidator.validate(email)) {
Expand Down Expand Up @@ -200,6 +202,7 @@ class RegisterViewModel(
val email =
state.value.emailTextState.text
.toString()
.normalizeEmail()
val username =
state.value.usernameTextState.text
.toString()
Expand Down Expand Up @@ -229,7 +232,10 @@ class RegisterViewModel(

private fun validateFormInputs(): Boolean {
val currentState = state.value
val email = currentState.emailTextState.text.toString()
val email =
currentState.emailTextState.text
.toString()
.normalizeEmail()
val username = currentState.usernameTextState.text.toString()
val password = currentState.passwordTextState.text.toString()
val confirmPassword = currentState.confirmPasswordTextState.text.toString()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,23 @@ class ForgotPasswordViewModelTest {
}
}

@Test
fun submitForgotPasswordRequestSendsTheEmailTrimmedAndLowercased() =
runTest(testDispatcher) {
val authService = FakeAuthService(forgotPasswordResult = Result.Success(Unit))
val viewModel = createViewModel(authService = authService)
viewModel.state.value.emailTextFieldState.edit {
replace(0, length, " Test.User@Example.COM ")
}
Snapshot.sendApplyNotifications()
advanceUntilIdle()

viewModel.submitForgotPasswordRequest()
advanceUntilIdle()

assertEquals(listOf("test.user@example.com"), authService.forgotPasswordEmails)
}

@Test
fun submitForgotPasswordRequestFailureSetsError() =
runTest(testDispatcher) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,28 @@ class LoginViewModelTest {
assertEquals(1, authService.loginCalls)
}

@Test
fun loginSendsTheEmailTrimmedAndLowercased() =
runTest(testDispatcher) {
val authService = FakeAuthService(loginResult = Result.Success(FAKE_AUTH_INFO))
val viewModel = createViewModel(authService = authService)
// Written straight to the state, the way autofill or a prefill does: the field's
// input transformation never runs, so the ViewModel has to normalize it.
viewModel.state.value.emailTextFieldState.edit {
replace(0, length, " Test.User@Example.COM ")
}
viewModel.state.value.passwordTextFieldState.edit {
replace(0, length, "password123")
}
Snapshot.sendApplyNotifications()
advanceUntilIdle()

viewModel.onLogin()
advanceUntilIdle()

assertEquals(listOf("test.user@example.com"), authService.loginEmails)
}

@Test
fun isLoggingInIsFalseAfterFailedLogin() =
runTest(testDispatcher) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,22 @@ class RegisterViewModelTest {
assertNull(viewModel.state.value.confirmPasswordError)
}

@Test
fun `register sends the email trimmed and lower cased`() =
runTest(testDispatcher) {
val authService = FakeAuthService()
val viewModel = RegisterViewModel(authService)
activateState(viewModel)
fillValidInputs(viewModel)
viewModel.state.value.emailTextState
.setTextAndPlaceCursorAtEnd(" Test.User@Example.COM ")

viewModel.register()

assertEquals(listOf("test.user@example.com"), authService.registerEmails)
assertNull(viewModel.state.value.emailError)
}

@Test
fun `register conflict emits registration error event with account exists message`() =
runTest(testDispatcher) {
Expand Down
Loading