feat: account deletion with re-authentication flow (batch v0.2.7)

- Add reauthenticateWithEmail to AuthRepository + FirebaseAuthDataSource
- DeleteAccountScreen: re-auth dialog for email users (password prompt), Google user guidance
- DeleteAccountViewModel: handle FirebaseAuthRecentLoginRequiredException, re-auth submission
- Reorder delete flow: auth account deleted first, then couple leave + user data cleanup
This commit is contained in:
null 2026-06-19 19:57:07 -05:00
parent 30fddcc2df
commit cbaa68ef2e
4 changed files with 136 additions and 10 deletions

View File

@ -2,6 +2,7 @@ package app.closer.data.remote
import app.closer.domain.model.AuthState import app.closer.domain.model.AuthState
import app.closer.domain.model.GoogleSignInResult import app.closer.domain.model.GoogleSignInResult
import com.google.firebase.auth.EmailAuthProvider
import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.GoogleAuthProvider import com.google.firebase.auth.GoogleAuthProvider
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
@ -90,6 +91,16 @@ class FirebaseAuthDataSource @Inject constructor() {
fun signOut() = auth.signOut() fun signOut() = auth.signOut()
suspend fun reauthenticateWithEmail(email: String, password: String): Unit =
suspendCancellableCoroutine { cont ->
val credential = EmailAuthProvider.getCredential(email, password)
auth.currentUser
?.reauthenticate(credential)
?.addOnSuccessListener { cont.resume(Unit) }
?.addOnFailureListener { cont.resumeWithException(it) }
?: cont.resumeWithException(IllegalStateException("No signed-in user"))
}
suspend fun deleteAccount(): Unit = suspend fun deleteAccount(): Unit =
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
auth.currentUser auth.currentUser

View File

@ -49,6 +49,9 @@ class FirebaseAuthRepositoryImpl @Inject constructor(
override suspend fun signOut() = dataSource.signOut() override suspend fun signOut() = dataSource.signOut()
override suspend fun reauthenticateWithEmail(email: String, password: String): Result<Unit> =
runCatching { dataSource.reauthenticateWithEmail(email, password) }
override suspend fun deleteAccount(): Result<Unit> = override suspend fun deleteAccount(): Result<Unit> =
runCatching { dataSource.deleteAccount() } runCatching { dataSource.deleteAccount() }

View File

@ -17,5 +17,6 @@ interface AuthRepository {
suspend fun signUpWithEmail(email: String, password: String): Result<String> suspend fun signUpWithEmail(email: String, password: String): Result<String>
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 deleteAccount(): Result<Unit> suspend fun deleteAccount(): Result<Unit>
} }

View File

@ -40,6 +40,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
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.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@ -53,6 +55,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@ -63,7 +68,11 @@ data class DeleteAccountUiState(
val canDelete: Boolean = false, val canDelete: Boolean = false,
val acknowledged: Boolean = false, val acknowledged: Boolean = false,
val error: String? = null, val error: String? = null,
val navigateTo: String? = null val navigateTo: String? = null,
val needsReauth: Boolean = false,
val reauthPassword: String = "",
val isReauthing: Boolean = false,
val isGoogleUser: Boolean = false
) )
@HiltViewModel @HiltViewModel
@ -76,27 +85,64 @@ class DeleteAccountViewModel @Inject constructor(
private val _uiState = MutableStateFlow(DeleteAccountUiState()) private val _uiState = MutableStateFlow(DeleteAccountUiState())
val uiState: StateFlow<DeleteAccountUiState> = _uiState.asStateFlow() val uiState: StateFlow<DeleteAccountUiState> = _uiState.asStateFlow()
fun requestDelete() = _uiState.update { it.copy(showConfirm = true) } fun requestDelete() = _uiState.update {
it.copy(showConfirm = true, isGoogleUser = authRepository.isGoogleAccount)
}
fun dismissDelete() = _uiState.update { it.copy(showConfirm = false) } fun dismissDelete() = _uiState.update { it.copy(showConfirm = false) }
fun dismissReauth() = _uiState.update { it.copy(needsReauth = false, reauthPassword = "", error = null) }
fun setAcknowledged(value: Boolean) = _uiState.update { it.copy(acknowledged = value, canDelete = value) } fun setAcknowledged(value: Boolean) = _uiState.update { it.copy(acknowledged = value, canDelete = value) }
fun onReauthPasswordChanged(pw: String) = _uiState.update { it.copy(reauthPassword = pw, error = null) }
fun confirmDelete() { fun confirmDelete() {
if (!uiState.value.canDelete) return if (!uiState.value.canDelete) return
val uid = authRepository.currentUserId val uid = authRepository.currentUserId ?: return
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(showConfirm = false, isDeleting = true, error = null) } _uiState.update { it.copy(showConfirm = false, isDeleting = true, error = null) }
if (uid != null) { // Delete auth account FIRST so a later cleanup failure never leaves the user
// Leave couple first so the partner is notified and the couple doc // with data gone but an active account. If this throws RecentLoginRequired
// is cleaned up before the auth account disappears. // we stop before touching any data.
runCatching { coupleRepository.leaveCouple(uid) }
runCatching { userRepository.deleteUserData(uid) }
}
authRepository.deleteAccount() authRepository.deleteAccount()
.onSuccess { .onSuccess {
runCatching { coupleRepository.leaveCouple(uid) }
runCatching { userRepository.deleteUserData(uid) }
_uiState.update { it.copy(isDeleting = false, navigateTo = AppRoute.ONBOARDING) } _uiState.update { it.copy(isDeleting = false, navigateTo = AppRoute.ONBOARDING) }
} }
.onFailure { e -> .onFailure { e ->
_uiState.update { it.copy(isDeleting = false, error = e.message ?: "Could not delete account.") } _uiState.update { it.copy(isDeleting = false) }
if (e is com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException) {
_uiState.update { it.copy(needsReauth = true, error = null) }
} else {
_uiState.update { it.copy(error = e.message ?: "Could not delete account.") }
}
}
}
}
fun submitReauth() {
val password = _uiState.value.reauthPassword
val email = authRepository.currentUserEmail ?: return
if (password.isBlank()) {
_uiState.update { it.copy(error = "Enter your password to continue.") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isReauthing = true, error = null) }
authRepository.reauthenticateWithEmail(email, password)
.onSuccess {
_uiState.update { it.copy(isReauthing = false, needsReauth = false, reauthPassword = "", canDelete = true) }
confirmDelete()
}
.onFailure { e ->
_uiState.update {
it.copy(
isReauthing = false,
error = if (e.message?.contains("password", ignoreCase = true) == true ||
e.message?.contains("credential", ignoreCase = true) == true)
"Incorrect password. Please try again."
else
e.message ?: "Re-authentication failed."
)
}
} }
} }
} }
@ -160,6 +206,71 @@ fun DeleteAccountScreen(
) )
} }
if (state.needsReauth) {
AlertDialog(
onDismissRequest = viewModel::dismissReauth,
title = { Text("Confirm it's you") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (state.isGoogleUser) {
Text(
"For security, please sign out and sign back in with Google, then try deleting your account again.",
style = MaterialTheme.typography.bodyMedium
)
} else {
Text(
"For security, re-enter your password before deleting your account.",
style = MaterialTheme.typography.bodyMedium
)
OutlinedTextField(
value = state.reauthPassword,
onValueChange = viewModel::onReauthPasswordChanged,
label = { Text("Password") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = state.error != null,
supportingText = state.error?.let { err ->
{ Text(err, color = SettingsDanger) }
},
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = SettingsDanger,
unfocusedBorderColor = SettingsMuted.copy(alpha = 0.4f),
cursorColor = SettingsDanger
),
modifier = Modifier.fillMaxWidth()
)
}
}
},
confirmButton = {
if (state.isGoogleUser) {
TextButton(onClick = viewModel::dismissReauth) { Text("OK") }
} else {
Button(
onClick = viewModel::submitReauth,
enabled = !state.isReauthing,
colors = ButtonDefaults.buttonColors(
containerColor = SettingsDanger,
contentColor = Color.White
)
) {
if (state.isReauthing) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = Color.White, strokeWidth = 2.dp)
} else {
Text("Confirm", color = Color.White)
}
}
}
},
dismissButton = {
if (!state.isGoogleUser) {
TextButton(onClick = viewModel::dismissReauth) { Text("Cancel") }
}
}
)
}
Scaffold( Scaffold(
containerColor = Color.Transparent, containerColor = Color.Transparent,
modifier = Modifier.background(SettingsBackgroundBrush), modifier = Modifier.background(SettingsBackgroundBrush),