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.GoogleSignInResult
import com.google.firebase.auth.EmailAuthProvider
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.GoogleAuthProvider
import kotlinx.coroutines.channels.awaitClose
@ -90,6 +91,16 @@ class FirebaseAuthDataSource @Inject constructor() {
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 =
suspendCancellableCoroutine { cont ->
auth.currentUser

View File

@ -49,6 +49,9 @@ class FirebaseAuthRepositoryImpl @Inject constructor(
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> =
runCatching { dataSource.deleteAccount() }

View File

@ -17,5 +17,6 @@ interface AuthRepository {
suspend fun signUpWithEmail(email: String, password: String): Result<String>
suspend fun sendPasswordResetEmail(email: String): Result<Unit>
suspend fun signOut()
suspend fun reauthenticateWithEmail(email: String, password: String): 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.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@ -53,6 +55,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@ -63,7 +68,11 @@ data class DeleteAccountUiState(
val canDelete: Boolean = false,
val acknowledged: Boolean = false,
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
@ -76,27 +85,64 @@ class DeleteAccountViewModel @Inject constructor(
private val _uiState = MutableStateFlow(DeleteAccountUiState())
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 dismissReauth() = _uiState.update { it.copy(needsReauth = false, reauthPassword = "", error = null) }
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() {
if (!uiState.value.canDelete) return
val uid = authRepository.currentUserId
val uid = authRepository.currentUserId ?: return
viewModelScope.launch {
_uiState.update { it.copy(showConfirm = false, isDeleting = true, error = null) }
if (uid != null) {
// Leave couple first so the partner is notified and the couple doc
// is cleaned up before the auth account disappears.
runCatching { coupleRepository.leaveCouple(uid) }
runCatching { userRepository.deleteUserData(uid) }
}
// Delete auth account FIRST so a later cleanup failure never leaves the user
// with data gone but an active account. If this throws RecentLoginRequired
// we stop before touching any data.
authRepository.deleteAccount()
.onSuccess {
runCatching { coupleRepository.leaveCouple(uid) }
runCatching { userRepository.deleteUserData(uid) }
_uiState.update { it.copy(isDeleting = false, navigateTo = AppRoute.ONBOARDING) }
}
.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(
containerColor = Color.Transparent,
modifier = Modifier.background(SettingsBackgroundBrush),