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.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
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue