feat(auth): remove anonymous sign-in, add email verification flow (batch 0.3.0)

This commit is contained in:
null 2026-06-21 21:18:30 -05:00
parent 423081bdb2
commit 893c88d774
7 changed files with 47 additions and 39 deletions

View File

@ -23,6 +23,7 @@ class FirebaseAuthDataSource @Inject constructor() {
val currentUserEmail: String? get() = auth.currentUser?.email
val isSignedIn: Boolean get() = auth.currentUser != null
val isAnonymous: Boolean get() = auth.currentUser?.isAnonymous ?: false
val isEmailVerified: Boolean get() = auth.currentUser?.isEmailVerified ?: false
val isGoogleAccount: Boolean
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) }
?: 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 =
suspendCancellableCoroutine { cont ->
auth.signInWithEmailAndPassword(email, password)
@ -63,6 +57,31 @@ class FirebaseAuthDataSource @Inject constructor() {
.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 =
suspendCancellableCoroutine { cont ->
auth.sendPasswordResetEmail(email)

View File

@ -21,17 +21,13 @@ class FirebaseAuthRepositoryImpl @Inject constructor(
override val isSignedIn: Boolean get() = dataSource.isSignedIn
override val isAnonymous: Boolean get() = dataSource.isAnonymous
override val isGoogleAccount: Boolean get() = dataSource.isGoogleAccount
override val isEmailVerified: Boolean get() = dataSource.isEmailVerified
override suspend fun signInWithGoogle(idToken: String): Result<GoogleSignInResult> =
withRateLimit(AuthRateLimiter.Flow.LOGIN) {
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> =
withRateLimit(AuthRateLimiter.Flow.LOGIN) {
runCatching { dataSource.signInWithEmail(email, password) }
@ -42,6 +38,12 @@ class FirebaseAuthRepositoryImpl @Inject constructor(
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> =
withRateLimit(AuthRateLimiter.Flow.PASSWORD_RESET) {
runCatching { dataSource.sendPasswordResetEmail(email) }

View File

@ -11,10 +11,12 @@ interface AuthRepository {
val isSignedIn: Boolean
val isAnonymous: Boolean
val isGoogleAccount: Boolean
val isEmailVerified: Boolean
suspend fun signInWithGoogle(idToken: String): Result<GoogleSignInResult>
suspend fun signInAnonymously(): Result<String>
suspend fun signInWithEmail(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 signOut()
suspend fun reauthenticateWithEmail(email: String, password: String): Result<Unit>

View File

@ -2,7 +2,6 @@ package app.closer.ui.auth
import app.closer.R
import androidx.compose.foundation.background
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.credentials.CredentialManager
import androidx.credentials.CustomCredential
@ -31,7 +30,6 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
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))
TextButton(onClick = { onNavigate(AppRoute.SIGN_UP) }) {

View File

@ -58,10 +58,6 @@ class LoginViewModel @Inject constructor(
}
}
fun signInAnonymously() {
attemptSignIn { authRepository.signInAnonymously() }
}
fun reportError(message: String) = _uiState.update { it.copy(error = message) }
private fun attemptSignIn(action: suspend () -> Result<String>) {

View File

@ -149,7 +149,7 @@ fun SignUpScreen(
},
supportingText = {
Text(
"At least 6 characters",
"At least 8 characters, with letters and numbers",
style = MaterialTheme.typography.bodySmall,
color = AuthMuted
)

View File

@ -37,15 +37,21 @@ class SignUpViewModel @Inject constructor(
fun signUp() {
val state = _uiState.value
val pw = state.password
when {
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 }
state.password != state.confirmPassword -> { _uiState.update { it.copy(error = "Passwords don't match.") }; return }
pw.length < 8 -> { _uiState.update { it.copy(error = "Password must be at least 8 characters.") }; 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) }
viewModelScope.launch {
authRepository.signUpWithEmail(state.email.trim(), state.password)
.onSuccess { _uiState.update { it.copy(isLoading = false, success = true) } }
authRepository.signUpWithEmail(state.email.trim(), pw)
.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 ->
val msg = friendlyError(e)
_uiState.update { it.copy(isLoading = false, error = msg) }