feat: require PIN to delete protected profiles

This commit is contained in:
KhooLy 2026-07-29 15:49:23 +03:00
parent 599e82e7ff
commit b7d588f9c8
5 changed files with 41 additions and 12 deletions

View file

@ -227,7 +227,7 @@ fun FluxaApp(
onRemoveAvatarClick: () -> Unit = {},
onPickPackAvatarClick: (String) -> Unit = {},
onProfileSave: (ProfileEditUiModel) -> Unit = {},
onProfileDelete: (() -> Unit)? = null,
onProfileDelete: (suspend (String?) -> Boolean)? = null,
onProfileEditCancel: () -> Unit = {},
onPickBackgroundClick: () -> Unit = {},
playerState: PlayerRenderState? = null,

View file

@ -571,12 +571,12 @@ fun FluxaAppHost(
}
},
onProfileDelete = (appState.uiState.editingProfile as? ProfileEditTarget.Existing)?.let { existing ->
{
scope.launch {
profileStore?.deleteProfile(existing.id)
{ pin ->
val deleted = profileStore?.deleteProfile(existing.id, pin) == true
if (deleted) {
appState.beginProfileEdit(null)
}
Unit
deleted
}
},
onProfileEditCancel = { appState.beginProfileEdit(null) },

View file

@ -70,7 +70,7 @@ interface ProfileDataSource {
suspend fun attemptPin(profileId: String, pin: String)
suspend fun confirmBiometricUnlock(profileId: String)
suspend fun cancelPinUnlock()
suspend fun deleteProfile(id: String)
suspend fun deleteProfile(id: String, pin: String? = null): Boolean
suspend fun saveProfile(edit: ProfileEditUiModel): String
suspend fun setPickerBackground(url: String?)
suspend fun addAvatarPack(repositoryUrl: String)

View file

@ -36,6 +36,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -49,6 +50,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import com.fluxa.app.common.AppStrings
import com.fluxa.app.shared.feature.settings.SettingsGroupCard
import com.fluxa.app.shared.feature.settings.SettingsSectionHeader
@ -65,7 +67,7 @@ fun ProfileEditScreen(
onPickAvatarClick: () -> Unit,
onRemoveAvatarClick: () -> Unit,
onSave: (ProfileEditUiModel) -> Unit,
onDelete: (() -> Unit)?,
onDelete: (suspend (String?) -> Boolean)?,
onCancel: () -> Unit,
avatarPacks: List<ProfileAvatarPackUiModel> = emptyList(),
onPickPackAvatarClick: (String) -> Unit = {},
@ -77,6 +79,9 @@ fun ProfileEditScreen(
var biometricEnabled by remember(initialProfile?.id) { mutableStateOf(initialProfile?.biometricEnabled == true) }
var showAvatarSheet by remember { mutableStateOf(false) }
var confirmDelete by remember(initialProfile?.id) { mutableStateOf(false) }
var deletePin by remember(initialProfile?.id) { mutableStateOf("") }
var deletePinError by remember(initialProfile?.id) { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val pinValid = pin.isEmpty() || pin.length == 4
val willHavePin = !removePin && (pin.length == 4 || initialProfile?.hasPin == true)
@ -228,11 +233,30 @@ fun ProfileEditScreen(
AlertDialog(
onDismissRequest = { confirmDelete = false },
title = { Text(AppStrings.t(language, "profiles.delete_confirm_title")) },
text = { Text(AppStrings.t(language, "profiles.delete_confirm_message")) },
text = {
Column {
Text(AppStrings.t(language, "profiles.delete_confirm_message"))
if (initialProfile?.hasPin == true) {
OutlinedTextField(
value = deletePin,
onValueChange = {
deletePin = it.filter(Char::isDigit).take(4)
deletePinError = false
},
label = { Text(AppStrings.t(language, "profiles.pin_prompt")) },
isError = deletePinError,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword)
)
}
}
},
confirmButton = {
TextButton(onClick = {
confirmDelete = false
onDelete()
scope.launch {
val deleted = onDelete(if (initialProfile?.hasPin == true) deletePin else null)
if (deleted) confirmDelete = false else deletePinError = true
}
}) { Text(AppStrings.t(language, "profiles.delete"), color = FluxaColors.errorRed) }
},
dismissButton = {

View file

@ -37,7 +37,7 @@ class ProfileStore(
}
}
suspend fun deleteProfile(id: String) = dataSource.deleteProfile(id)
suspend fun deleteProfile(id: String, pin: String? = null): Boolean = dataSource.deleteProfile(id, pin)
suspend fun saveProfile(edit: ProfileEditUiModel): String = dataSource.saveProfile(edit)
}
@ -116,7 +116,12 @@ class SharedProfileDataSource(
pinError.value = false
}
override suspend fun deleteProfile(id: String) = store.delete(id)
override suspend fun deleteProfile(id: String, pin: String?): Boolean {
val expectedPinHash = store.pinHash(id)
if (!expectedPinHash.isNullOrBlank() && PinHasher.hash(pin.orEmpty()) != expectedPinHash) return false
store.delete(id)
return true
}
override suspend fun saveProfile(edit: ProfileEditUiModel): String {
val pinHash = when {