diff --git a/app/src/main/java/app/closer/data/remote/FirebaseAuthDataSource.kt b/app/src/main/java/app/closer/data/remote/FirebaseAuthDataSource.kt index 4283dcda..b934c6ee 100644 --- a/app/src/main/java/app/closer/data/remote/FirebaseAuthDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirebaseAuthDataSource.kt @@ -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 diff --git a/app/src/main/java/app/closer/data/repository/FirebaseAuthRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/FirebaseAuthRepositoryImpl.kt index f1e993fd..79aa06af 100644 --- a/app/src/main/java/app/closer/data/repository/FirebaseAuthRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/FirebaseAuthRepositoryImpl.kt @@ -49,6 +49,9 @@ class FirebaseAuthRepositoryImpl @Inject constructor( override suspend fun signOut() = dataSource.signOut() + override suspend fun reauthenticateWithEmail(email: String, password: String): Result = + runCatching { dataSource.reauthenticateWithEmail(email, password) } + override suspend fun deleteAccount(): Result = runCatching { dataSource.deleteAccount() } diff --git a/app/src/main/java/app/closer/domain/repository/AuthRepository.kt b/app/src/main/java/app/closer/domain/repository/AuthRepository.kt index d79073b1..6de94077 100644 --- a/app/src/main/java/app/closer/domain/repository/AuthRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/AuthRepository.kt @@ -17,5 +17,6 @@ interface AuthRepository { suspend fun signUpWithEmail(email: String, password: String): Result suspend fun sendPasswordResetEmail(email: String): Result suspend fun signOut() + suspend fun reauthenticateWithEmail(email: String, password: String): Result suspend fun deleteAccount(): Result } diff --git a/app/src/main/java/app/closer/ui/settings/DeleteAccountScreen.kt b/app/src/main/java/app/closer/ui/settings/DeleteAccountScreen.kt index 23678199..37c660a2 100644 --- a/app/src/main/java/app/closer/ui/settings/DeleteAccountScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/DeleteAccountScreen.kt @@ -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 = _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),