feat(auth): remove anonymous sign-in, add email verification flow (batch 0.3.0)
This commit is contained in:
parent
423081bdb2
commit
893c88d774
|
|
@ -23,6 +23,7 @@ class FirebaseAuthDataSource @Inject constructor() {
|
||||||
val currentUserEmail: String? get() = auth.currentUser?.email
|
val currentUserEmail: String? get() = auth.currentUser?.email
|
||||||
val isSignedIn: Boolean get() = auth.currentUser != null
|
val isSignedIn: Boolean get() = auth.currentUser != null
|
||||||
val isAnonymous: Boolean get() = auth.currentUser?.isAnonymous ?: false
|
val isAnonymous: Boolean get() = auth.currentUser?.isAnonymous ?: false
|
||||||
|
val isEmailVerified: Boolean get() = auth.currentUser?.isEmailVerified ?: false
|
||||||
val isGoogleAccount: Boolean
|
val isGoogleAccount: Boolean
|
||||||
get() = auth.currentUser?.providerData?.any { it.providerId == GoogleAuthProvider.PROVIDER_ID } == true
|
get() = auth.currentUser?.providerData?.any { it.providerId == GoogleAuthProvider.PROVIDER_ID } == true
|
||||||
|
|
||||||
|
|
@ -42,13 +43,6 @@ class FirebaseAuthDataSource @Inject constructor() {
|
||||||
auth.currentUser?.let { AuthState.Authenticated(it.uid, it.isAnonymous) }
|
auth.currentUser?.let { AuthState.Authenticated(it.uid, it.isAnonymous) }
|
||||||
?: AuthState.Unauthenticated
|
?: AuthState.Unauthenticated
|
||||||
|
|
||||||
suspend fun signInAnonymously(): String =
|
|
||||||
suspendCancellableCoroutine { cont ->
|
|
||||||
auth.signInAnonymously()
|
|
||||||
.addOnSuccessListener { cont.resume(it.user?.uid ?: "") }
|
|
||||||
.addOnFailureListener { cont.resumeWithException(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun signInWithEmail(email: String, password: String): String =
|
suspend fun signInWithEmail(email: String, password: String): String =
|
||||||
suspendCancellableCoroutine { cont ->
|
suspendCancellableCoroutine { cont ->
|
||||||
auth.signInWithEmailAndPassword(email, password)
|
auth.signInWithEmailAndPassword(email, password)
|
||||||
|
|
@ -63,6 +57,31 @@ class FirebaseAuthDataSource @Inject constructor() {
|
||||||
.addOnFailureListener { cont.resumeWithException(it) }
|
.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun sendEmailVerification(): Unit =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
val user = auth.currentUser
|
||||||
|
if (user == null) {
|
||||||
|
cont.resumeWithException(IllegalStateException("No signed-in user"))
|
||||||
|
return@suspendCancellableCoroutine
|
||||||
|
}
|
||||||
|
user.sendEmailVerification()
|
||||||
|
.addOnSuccessListener { cont.resume(Unit) }
|
||||||
|
.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refreshes the cached FirebaseUser so [isEmailVerified] reflects server state. */
|
||||||
|
suspend fun reloadUser(): Unit =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
val user = auth.currentUser
|
||||||
|
if (user == null) {
|
||||||
|
cont.resumeWithException(IllegalStateException("No signed-in user"))
|
||||||
|
return@suspendCancellableCoroutine
|
||||||
|
}
|
||||||
|
user.reload()
|
||||||
|
.addOnSuccessListener { cont.resume(Unit) }
|
||||||
|
.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun sendPasswordResetEmail(email: String): Unit =
|
suspend fun sendPasswordResetEmail(email: String): Unit =
|
||||||
suspendCancellableCoroutine { cont ->
|
suspendCancellableCoroutine { cont ->
|
||||||
auth.sendPasswordResetEmail(email)
|
auth.sendPasswordResetEmail(email)
|
||||||
|
|
|
||||||
|
|
@ -21,17 +21,13 @@ class FirebaseAuthRepositoryImpl @Inject constructor(
|
||||||
override val isSignedIn: Boolean get() = dataSource.isSignedIn
|
override val isSignedIn: Boolean get() = dataSource.isSignedIn
|
||||||
override val isAnonymous: Boolean get() = dataSource.isAnonymous
|
override val isAnonymous: Boolean get() = dataSource.isAnonymous
|
||||||
override val isGoogleAccount: Boolean get() = dataSource.isGoogleAccount
|
override val isGoogleAccount: Boolean get() = dataSource.isGoogleAccount
|
||||||
|
override val isEmailVerified: Boolean get() = dataSource.isEmailVerified
|
||||||
|
|
||||||
override suspend fun signInWithGoogle(idToken: String): Result<GoogleSignInResult> =
|
override suspend fun signInWithGoogle(idToken: String): Result<GoogleSignInResult> =
|
||||||
withRateLimit(AuthRateLimiter.Flow.LOGIN) {
|
withRateLimit(AuthRateLimiter.Flow.LOGIN) {
|
||||||
runCatching { dataSource.signInWithGoogle(idToken) }
|
runCatching { dataSource.signInWithGoogle(idToken) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun signInAnonymously(): Result<String> =
|
|
||||||
withRateLimit(AuthRateLimiter.Flow.ANONYMOUS) {
|
|
||||||
runCatching { dataSource.signInAnonymously() }
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun signInWithEmail(email: String, password: String): Result<String> =
|
override suspend fun signInWithEmail(email: String, password: String): Result<String> =
|
||||||
withRateLimit(AuthRateLimiter.Flow.LOGIN) {
|
withRateLimit(AuthRateLimiter.Flow.LOGIN) {
|
||||||
runCatching { dataSource.signInWithEmail(email, password) }
|
runCatching { dataSource.signInWithEmail(email, password) }
|
||||||
|
|
@ -42,6 +38,12 @@ class FirebaseAuthRepositoryImpl @Inject constructor(
|
||||||
runCatching { dataSource.signUpWithEmail(email, password) }
|
runCatching { dataSource.signUpWithEmail(email, password) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun sendEmailVerification(): Result<Unit> =
|
||||||
|
runCatching { dataSource.sendEmailVerification() }
|
||||||
|
|
||||||
|
override suspend fun reloadUser(): Result<Unit> =
|
||||||
|
runCatching { dataSource.reloadUser() }
|
||||||
|
|
||||||
override suspend fun sendPasswordResetEmail(email: String): Result<Unit> =
|
override suspend fun sendPasswordResetEmail(email: String): Result<Unit> =
|
||||||
withRateLimit(AuthRateLimiter.Flow.PASSWORD_RESET) {
|
withRateLimit(AuthRateLimiter.Flow.PASSWORD_RESET) {
|
||||||
runCatching { dataSource.sendPasswordResetEmail(email) }
|
runCatching { dataSource.sendPasswordResetEmail(email) }
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,12 @@ interface AuthRepository {
|
||||||
val isSignedIn: Boolean
|
val isSignedIn: Boolean
|
||||||
val isAnonymous: Boolean
|
val isAnonymous: Boolean
|
||||||
val isGoogleAccount: Boolean
|
val isGoogleAccount: Boolean
|
||||||
|
val isEmailVerified: Boolean
|
||||||
suspend fun signInWithGoogle(idToken: String): Result<GoogleSignInResult>
|
suspend fun signInWithGoogle(idToken: String): Result<GoogleSignInResult>
|
||||||
suspend fun signInAnonymously(): Result<String>
|
|
||||||
suspend fun signInWithEmail(email: String, password: String): Result<String>
|
suspend fun signInWithEmail(email: String, password: String): Result<String>
|
||||||
suspend fun signUpWithEmail(email: String, password: String): Result<String>
|
suspend fun signUpWithEmail(email: String, password: String): Result<String>
|
||||||
|
suspend fun sendEmailVerification(): Result<Unit>
|
||||||
|
suspend fun reloadUser(): Result<Unit>
|
||||||
suspend fun sendPasswordResetEmail(email: String): Result<Unit>
|
suspend fun sendPasswordResetEmail(email: String): Result<Unit>
|
||||||
suspend fun signOut()
|
suspend fun signOut()
|
||||||
suspend fun reauthenticateWithEmail(email: String, password: String): Result<Unit>
|
suspend fun reauthenticateWithEmail(email: String, password: String): Result<Unit>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package app.closer.ui.auth
|
||||||
|
|
||||||
import app.closer.R
|
import app.closer.R
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.credentials.CredentialManager
|
import androidx.credentials.CredentialManager
|
||||||
import androidx.credentials.CustomCredential
|
import androidx.credentials.CustomCredential
|
||||||
|
|
@ -31,7 +30,6 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
|
@ -213,21 +211,6 @@ fun LoginScreen(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = viewModel::signInAnonymously,
|
|
||||||
enabled = !state.isLoading,
|
|
||||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
|
||||||
colors = ButtonDefaults.outlinedButtonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.62f),
|
|
||||||
contentColor = AuthPrimaryDeep
|
|
||||||
),
|
|
||||||
border = BorderStroke(1.dp, AuthPrimaryDeep.copy(alpha = 0.28f))
|
|
||||||
) {
|
|
||||||
Text("Try without account", style = MaterialTheme.typography.labelLarge)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(28.dp))
|
Spacer(Modifier.height(28.dp))
|
||||||
|
|
||||||
TextButton(onClick = { onNavigate(AppRoute.SIGN_UP) }) {
|
TextButton(onClick = { onNavigate(AppRoute.SIGN_UP) }) {
|
||||||
|
|
|
||||||
|
|
@ -58,10 +58,6 @@ class LoginViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun signInAnonymously() {
|
|
||||||
attemptSignIn { authRepository.signInAnonymously() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reportError(message: String) = _uiState.update { it.copy(error = message) }
|
fun reportError(message: String) = _uiState.update { it.copy(error = message) }
|
||||||
|
|
||||||
private fun attemptSignIn(action: suspend () -> Result<String>) {
|
private fun attemptSignIn(action: suspend () -> Result<String>) {
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,7 @@ fun SignUpScreen(
|
||||||
},
|
},
|
||||||
supportingText = {
|
supportingText = {
|
||||||
Text(
|
Text(
|
||||||
"At least 6 characters",
|
"At least 8 characters, with letters and numbers",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = AuthMuted
|
color = AuthMuted
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -37,15 +37,21 @@ class SignUpViewModel @Inject constructor(
|
||||||
|
|
||||||
fun signUp() {
|
fun signUp() {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
|
val pw = state.password
|
||||||
when {
|
when {
|
||||||
state.email.isBlank() -> { _uiState.update { it.copy(error = "Please enter your email.") }; return }
|
state.email.isBlank() -> { _uiState.update { it.copy(error = "Please enter your email.") }; return }
|
||||||
state.password.length < 6 -> { _uiState.update { it.copy(error = "Password must be at least 6 characters.") }; return }
|
pw.length < 8 -> { _uiState.update { it.copy(error = "Password must be at least 8 characters.") }; return }
|
||||||
state.password != state.confirmPassword -> { _uiState.update { it.copy(error = "Passwords don't match.") }; return }
|
!pw.any { it.isLetter() } || !pw.any { it.isDigit() } -> { _uiState.update { it.copy(error = "Password must include both letters and numbers.") }; return }
|
||||||
|
pw != state.confirmPassword -> { _uiState.update { it.copy(error = "Passwords don't match.") }; return }
|
||||||
}
|
}
|
||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
authRepository.signUpWithEmail(state.email.trim(), state.password)
|
authRepository.signUpWithEmail(state.email.trim(), pw)
|
||||||
.onSuccess { _uiState.update { it.copy(isLoading = false, success = true) } }
|
.onSuccess {
|
||||||
|
// Best-effort: send a verification email. Don't block account creation if it fails.
|
||||||
|
authRepository.sendEmailVerification()
|
||||||
|
_uiState.update { it.copy(isLoading = false, success = true) }
|
||||||
|
}
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
val msg = friendlyError(e)
|
val msg = friendlyError(e)
|
||||||
_uiState.update { it.copy(isLoading = false, error = msg) }
|
_uiState.update { it.copy(isLoading = false, error = msg) }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue