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:
parent
30fddcc2df
commit
cbaa68ef2e
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue