From 802b089ad85e3a9c916acb7501d2aad220701386 Mon Sep 17 00:00:00 2001 From: Dany Khalife Date: Sat, 28 Mar 2026 17:59:18 -0700 Subject: [PATCH 1/2] add deletion request --- .../com/dkhalife/tasks/api/TaskWizardApi.kt | 6 + .../java/com/dkhalife/tasks/model/User.kt | 6 +- .../com/dkhalife/tasks/repo/UserRepository.kt | 30 +++ .../tasks/ui/navigation/AppNavigation.kt | 8 +- .../tasks/ui/screen/SettingsScreen.kt | 102 ++++++++ .../tasks/ui/screen/TaskListScreen.kt | 70 ++++-- .../dkhalife/tasks/viewmodel/UserViewModel.kt | 83 +++++++ android/app/src/main/res/values/strings.xml | 12 + apiserver/config/config.go | 7 +- apiserver/internal/apis/label.go | 3 +- apiserver/internal/apis/task.go | 3 +- apiserver/internal/apis/user.go | 29 ++- apiserver/internal/middleware/auth/auth.go | 14 +- .../migrations/006_account_deletion.go | 69 ++++++ apiserver/internal/models/user.go | 20 +- apiserver/internal/repos/user/user.go | 28 +++ apiserver/internal/repos/user/user_test.go | 221 ++++++++++++++++++ .../internal/services/scheduler/scheduler.go | 18 +- apiserver/internal/services/users/user.go | 61 +++++ .../internal/services/users/user_test.go | 119 ++++++++++ apiserver/internal/utils/database/database.go | 3 + .../utils/middleware/deletion_guard.go | 47 ++++ .../utils/middleware/deletion_guard_test.go | 105 +++++++++ apiserver/internal/ws/server.go | 25 ++ frontend/src/App.tsx | 2 + frontend/src/api/users.ts | 6 + frontend/src/components/DeletionBanner.tsx | 78 +++++++ frontend/src/models/user.ts | 1 + frontend/src/models/websocket.ts | 4 + frontend/src/store/userSlice.ts | 76 +++++- .../src/views/Settings/AccountDeletion.tsx | 162 +++++++++++++ frontend/src/views/Settings/Settings.tsx | 2 + 32 files changed, 1369 insertions(+), 51 deletions(-) create mode 100644 android/app/src/main/java/com/dkhalife/tasks/viewmodel/UserViewModel.kt create mode 100644 apiserver/internal/migrations/006_account_deletion.go create mode 100644 apiserver/internal/services/users/user_test.go create mode 100644 apiserver/internal/utils/middleware/deletion_guard.go create mode 100644 apiserver/internal/utils/middleware/deletion_guard_test.go create mode 100644 frontend/src/components/DeletionBanner.tsx create mode 100644 frontend/src/views/Settings/AccountDeletion.tsx diff --git a/android/app/src/main/java/com/dkhalife/tasks/api/TaskWizardApi.kt b/android/app/src/main/java/com/dkhalife/tasks/api/TaskWizardApi.kt index c196b1a5..9be8074e 100644 --- a/android/app/src/main/java/com/dkhalife/tasks/api/TaskWizardApi.kt +++ b/android/app/src/main/java/com/dkhalife/tasks/api/TaskWizardApi.kt @@ -68,4 +68,10 @@ interface TaskWizardApi { @PUT("api/v1/users/notifications") suspend fun updateNotificationSettings(@Body req: NotificationUpdateRequest): Response + + @POST("api/v1/users/deletion") + suspend fun requestAccountDeletion(): Response + + @DELETE("api/v1/users/deletion") + suspend fun cancelAccountDeletion(): Response } diff --git a/android/app/src/main/java/com/dkhalife/tasks/model/User.kt b/android/app/src/main/java/com/dkhalife/tasks/model/User.kt index 27fb62aa..fe63eaf5 100644 --- a/android/app/src/main/java/com/dkhalife/tasks/model/User.kt +++ b/android/app/src/main/java/com/dkhalife/tasks/model/User.kt @@ -1,7 +1,11 @@ package com.dkhalife.tasks.model +import com.google.gson.annotations.SerializedName + data class UserProfile( - val notifications: NotificationSettings = NotificationSettings() + val notifications: NotificationSettings = NotificationSettings(), + @SerializedName("deletion_requested_at") + val deletionRequestedAt: String? = null ) data class NotificationSettings( diff --git a/android/app/src/main/java/com/dkhalife/tasks/repo/UserRepository.kt b/android/app/src/main/java/com/dkhalife/tasks/repo/UserRepository.kt index 0e524659..55ae86ae 100644 --- a/android/app/src/main/java/com/dkhalife/tasks/repo/UserRepository.kt +++ b/android/app/src/main/java/com/dkhalife/tasks/repo/UserRepository.kt @@ -41,6 +41,36 @@ class UserRepository @Inject constructor( } } + suspend fun requestDeletion(): Result { + return try { + val response = api.requestAccountDeletion() + if (response.isSuccessful) { + Result.success(Unit) + } else { + telemetryManager.logError(TAG, "Failed to request account deletion: ${response.code()}") + Result.failure(Exception("Failed to request account deletion: ${response.code()}")) + } + } catch (e: Exception) { + telemetryManager.logError(TAG, "Failed to request account deletion: ${e.message}", e) + Result.failure(e) + } + } + + suspend fun cancelDeletion(): Result { + return try { + val response = api.cancelAccountDeletion() + if (response.isSuccessful) { + Result.success(Unit) + } else { + telemetryManager.logError(TAG, "Failed to cancel account deletion: ${response.code()}") + Result.failure(Exception("Failed to cancel account deletion: ${response.code()}")) + } + } catch (e: Exception) { + telemetryManager.logError(TAG, "Failed to cancel account deletion: ${e.message}", e) + Result.failure(e) + } + } + companion object { private const val TAG = "UserRepository" } diff --git a/android/app/src/main/java/com/dkhalife/tasks/ui/navigation/AppNavigation.kt b/android/app/src/main/java/com/dkhalife/tasks/ui/navigation/AppNavigation.kt index 8b35d61a..7ff1d985 100644 --- a/android/app/src/main/java/com/dkhalife/tasks/ui/navigation/AppNavigation.kt +++ b/android/app/src/main/java/com/dkhalife/tasks/ui/navigation/AppNavigation.kt @@ -46,6 +46,7 @@ import com.dkhalife.tasks.viewmodel.LabelViewModel import com.dkhalife.tasks.viewmodel.TaskFormViewModel import com.dkhalife.tasks.viewmodel.TaskHistoryViewModel import com.dkhalife.tasks.viewmodel.TaskListViewModel +import com.dkhalife.tasks.viewmodel.UserViewModel @Composable fun AppNavigation( @@ -125,9 +126,11 @@ fun AppNavigation( ) { composable(Screen.Tasks.route) { val viewModel: TaskListViewModel = hiltViewModel() + val userViewModel: UserViewModel = hiltViewModel() val isRefreshing by viewModel.isRefreshing.collectAsState() val taskGroups by viewModel.taskGroups.collectAsState() val expandedGroups by viewModel.expandedGroups.collectAsState() + val deletionRequestedAt by userViewModel.deletionRequestedAt.collectAsState() LaunchedEffect(taskGrouping) { viewModel.setTaskGrouping(taskGrouping) @@ -147,7 +150,8 @@ fun AppNavigation( onCreateTask = { navController.navigate(Routes.TASK_FORM_CREATE) }, onToggleGroup = { viewModel.toggleGroupExpanded(it) }, swipeSettings = swipeSettings, - inlineCompleteEnabled = inlineCompleteEnabled + inlineCompleteEnabled = inlineCompleteEnabled, + isPendingDeletion = deletionRequestedAt != null ) } @@ -168,8 +172,10 @@ fun AppNavigation( composable(Screen.Settings.route) { val authViewModel: AuthViewModel = hiltViewModel() + val userViewModel: UserViewModel = hiltViewModel() SettingsScreen( authViewModel = authViewModel, + userViewModel = userViewModel, themeMode = themeMode, onThemeModeChanged = onThemeModeChanged, taskGrouping = taskGrouping, diff --git a/android/app/src/main/java/com/dkhalife/tasks/ui/screen/SettingsScreen.kt b/android/app/src/main/java/com/dkhalife/tasks/ui/screen/SettingsScreen.kt index a4f185c4..89984ec5 100644 --- a/android/app/src/main/java/com/dkhalife/tasks/ui/screen/SettingsScreen.kt +++ b/android/app/src/main/java/com/dkhalife/tasks/ui/screen/SettingsScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.GridView import androidx.compose.material.icons.filled.Insights import androidx.compose.material.icons.filled.Palette @@ -35,7 +36,12 @@ import com.dkhalife.tasks.data.TaskGrouping import com.dkhalife.tasks.data.ThemeMode import com.dkhalife.tasks.data.calendar.CalendarRepository import com.dkhalife.tasks.viewmodel.AuthViewModel +import com.dkhalife.tasks.viewmodel.UserViewModel import kotlinx.coroutines.launch +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle private val CALENDAR_PERMISSIONS = arrayOf( Manifest.permission.READ_CALENDAR, @@ -46,6 +52,7 @@ private val CALENDAR_PERMISSIONS = arrayOf( @Composable fun SettingsScreen( authViewModel: AuthViewModel, + userViewModel: UserViewModel, themeMode: ThemeMode, onThemeModeChanged: (ThemeMode) -> Unit, taskGrouping: TaskGrouping, @@ -72,6 +79,11 @@ fun SettingsScreen( var errorMessage by remember { mutableStateOf(null) } + val deletionRequestedAt by userViewModel.deletionRequestedAt.collectAsState() + val isDeletionLoading by userViewModel.isLoading.collectAsState() + val deletionError by userViewModel.errorMessage.collectAsState() + var showDeletionConfirmDialog by remember { mutableStateOf(false) } + val calendarPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions() ) { permissions -> @@ -100,6 +112,44 @@ fun SettingsScreen( } ) } + + if (deletionError != null) { + AlertDialog( + onDismissRequest = { userViewModel.clearError() }, + title = { Text(stringResource(R.string.dialog_title_error)) }, + text = { Text(deletionError ?: "") }, + confirmButton = { + TextButton(onClick = { userViewModel.clearError() }) { + Text(stringResource(R.string.btn_ok)) + } + } + ) + } + + if (showDeletionConfirmDialog) { + AlertDialog( + onDismissRequest = { showDeletionConfirmDialog = false }, + icon = { Icon(Icons.Default.DeleteForever, contentDescription = null, tint = MaterialTheme.colorScheme.error) }, + title = { Text(stringResource(R.string.dialog_title_confirm_deletion)) }, + text = { Text(stringResource(R.string.dialog_confirm_deletion_message)) }, + confirmButton = { + TextButton( + onClick = { + showDeletionConfirmDialog = false + userViewModel.requestDeletion() + }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { + Text(stringResource(R.string.btn_confirm_delete_account)) + } + }, + dismissButton = { + TextButton(onClick = { showDeletionConfirmDialog = false }) { + Text(stringResource(R.string.btn_cancel)) + } + } + ) + } val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() Scaffold( @@ -400,6 +450,58 @@ fun SettingsScreen( Spacer(modifier = Modifier.height(20.dp)) + SectionHeader(stringResource(R.string.settings_header_account)) + + SettingsCard(icon = Icons.Default.DeleteForever, title = stringResource(R.string.settings_section_account_deletion)) { + if (deletionRequestedAt != null) { + val formattedTime = remember(deletionRequestedAt) { + try { + val instant = Instant.parse(deletionRequestedAt) + val deleteAt = instant.plusSeconds(24 * 60 * 60) + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) + .withZone(ZoneId.systemDefault()) + .format(deleteAt) + } catch (e: Exception) { + deletionRequestedAt ?: "" + } + } + Text( + text = stringResource(R.string.settings_account_deletion_pending_description, formattedTime), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = { userViewModel.cancelDeletion() }, + enabled = !isDeletionLoading, + modifier = Modifier.fillMaxWidth() + ) { + if (isDeletionLoading) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(stringResource(R.string.btn_cancel_deletion)) + } + } else { + Text( + text = stringResource(R.string.settings_account_deletion_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = { showDeletionConfirmDialog = true }, + enabled = !isDeletionLoading, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { + Text(stringResource(R.string.btn_delete_account)) + } + } + } + + Spacer(modifier = Modifier.height(20.dp)) + TextButton( onClick = { authViewModel.signOut() }, modifier = Modifier.fillMaxWidth(), diff --git a/android/app/src/main/java/com/dkhalife/tasks/ui/screen/TaskListScreen.kt b/android/app/src/main/java/com/dkhalife/tasks/ui/screen/TaskListScreen.kt index 622ce2f2..7dfbf15d 100644 --- a/android/app/src/main/java/com/dkhalife/tasks/ui/screen/TaskListScreen.kt +++ b/android/app/src/main/java/com/dkhalife/tasks/ui/screen/TaskListScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -18,6 +19,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CheckCircleOutline +import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon @@ -55,7 +57,8 @@ fun TaskListScreen( onCreateTask: () -> Unit, onToggleGroup: (String) -> Unit, swipeSettings: SwipeSettings = SwipeSettings(), - inlineCompleteEnabled: Boolean = true + inlineCompleteEnabled: Boolean = true, + isPendingDeletion: Boolean = false ){ val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val newTaskLabel = stringResource(R.string.btn_new_task) @@ -73,22 +76,52 @@ fun TaskListScreen( ) }, floatingActionButton = { - ExtendedFloatingActionButton( - onClick = onCreateTask, - icon = { Icon(Icons.Default.Add, contentDescription = null) }, - text = { Text(newTaskLabel) }, - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer - ) + if (!isPendingDeletion) { + ExtendedFloatingActionButton( + onClick = onCreateTask, + icon = { Icon(Icons.Default.Add, contentDescription = null) }, + text = { Text(newTaskLabel) }, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + } } ) { padding -> - PullToRefreshBox( - isRefreshing = isRefreshing, - onRefresh = onRefresh, + Column( modifier = Modifier .fillMaxSize() .padding(padding) ) { + if (isPendingDeletion) { + androidx.compose.material3.Surface( + color = MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.fillMaxWidth() + ) { + androidx.compose.foundation.layout.Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.settings_section_account_deletion), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } + + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + modifier = Modifier.fillMaxSize() + ) { AnimatedContent( targetState = taskGroups.isEmpty() && !isRefreshing, transitionSpec = { fadeIn() togetherWith fadeOut() }, @@ -141,14 +174,14 @@ fun TaskListScreen( items(group.tasks, key = { "${group.key}_${it.id}" }) { task -> TaskItem( task = task, - onComplete = { onCompleteTask(task.id) }, - onSkip = { onSkipTask(task.id) }, - onDelete = { onDeleteTask(task.id) }, - onClick = { onTaskClick(task.id) }, + onComplete = if (isPendingDeletion) ({}) else { { onCompleteTask(task.id) } }, + onSkip = if (isPendingDeletion) ({}) else { { onSkipTask(task.id) } }, + onDelete = if (isPendingDeletion) ({}) else { { onDeleteTask(task.id) } }, + onClick = if (isPendingDeletion) ({}) else { { onTaskClick(task.id) } }, onViewHistory = { onViewHistory(task.id) }, - onCompleteAndEndRecurrence = { onCompleteAndEndRecurrenceTask(task.id) }, - swipeSettings = swipeSettings, - inlineCompleteEnabled = inlineCompleteEnabled + onCompleteAndEndRecurrence = if (isPendingDeletion) ({}) else { { onCompleteAndEndRecurrenceTask(task.id) } }, + swipeSettings = if (isPendingDeletion) SwipeSettings(enabled = false) else swipeSettings, + inlineCompleteEnabled = inlineCompleteEnabled && !isPendingDeletion ) } } @@ -159,3 +192,4 @@ fun TaskListScreen( } } } +} diff --git a/android/app/src/main/java/com/dkhalife/tasks/viewmodel/UserViewModel.kt b/android/app/src/main/java/com/dkhalife/tasks/viewmodel/UserViewModel.kt new file mode 100644 index 00000000..09f36f7e --- /dev/null +++ b/android/app/src/main/java/com/dkhalife/tasks/viewmodel/UserViewModel.kt @@ -0,0 +1,83 @@ +package com.dkhalife.tasks.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.dkhalife.tasks.repo.UserRepository +import com.dkhalife.tasks.telemetry.TelemetryManager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class UserViewModel @Inject constructor( + private val userRepository: UserRepository, + private val telemetryManager: TelemetryManager +) : ViewModel() { + + private val _deletionRequestedAt = MutableStateFlow(null) + val deletionRequestedAt: StateFlow = _deletionRequestedAt + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading + + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage + + init { + loadProfile() + } + + private fun loadProfile() { + viewModelScope.launch { + userRepository.getUserProfile() + .onSuccess { profile -> + _deletionRequestedAt.value = profile.deletionRequestedAt + } + .onFailure { e -> + telemetryManager.logError(TAG, "Failed to load profile: ${e.message}", e) + } + } + } + + fun requestDeletion() { + viewModelScope.launch { + _isLoading.value = true + _errorMessage.value = null + userRepository.requestDeletion() + .onSuccess { + _deletionRequestedAt.value = java.time.Instant.now().toString() + } + .onFailure { e -> + telemetryManager.logError(TAG, "Failed to request deletion: ${e.message}", e) + _errorMessage.value = e.message + } + _isLoading.value = false + } + } + + fun cancelDeletion() { + viewModelScope.launch { + _isLoading.value = true + _errorMessage.value = null + userRepository.cancelDeletion() + .onSuccess { + _deletionRequestedAt.value = null + } + .onFailure { e -> + telemetryManager.logError(TAG, "Failed to cancel deletion: ${e.message}", e) + _errorMessage.value = e.message + } + _isLoading.value = false + } + } + + fun clearError() { + _errorMessage.value = null + } + + companion object { + private const val TAG = "UserViewModel" + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 5408f9e9..18f099b7 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -275,6 +275,18 @@ On time %1$s late + + Account + Account Deletion + Permanently delete your account and all associated data. A 24-hour grace period applies during which you can cancel. + Your account is scheduled for permanent deletion on %1$s. All tasks, labels, and data will be erased. Cancel below to restore full access. + Delete My Account + Cancel Deletion + Confirm Account Deletion + Your account will be locked immediately and all data permanently deleted after 24 hours. You can cancel during this window. + Yes, delete my account + Account pending deletion on %1$s. Writes are disabled. + Custom Weekly diff --git a/apiserver/config/config.go b/apiserver/config/config.go index f836b260..8404277e 100644 --- a/apiserver/config/config.go +++ b/apiserver/config/config.go @@ -48,9 +48,10 @@ type ServerConfig struct { } type SchedulerConfig struct { - DueFrequency time.Duration `mapstructure:"due_frequency" yaml:"due_frequency" default:"5m"` - OverdueFrequency time.Duration `mapstructure:"overdue_frequency" yaml:"overdue_frequency" default:"1d"` - NotificationCleanup time.Duration `mapstructure:"notification_cleanup" yaml:"notification_cleanup" default:"10m"` + DueFrequency time.Duration `mapstructure:"due_frequency" yaml:"due_frequency" default:"5m"` + OverdueFrequency time.Duration `mapstructure:"overdue_frequency" yaml:"overdue_frequency" default:"1d"` + NotificationCleanup time.Duration `mapstructure:"notification_cleanup" yaml:"notification_cleanup" default:"10m"` + AccountDeletionFrequency time.Duration `mapstructure:"account_deletion_frequency" yaml:"account_deletion_frequency" default:"15m"` } func LoadConfig(configFile string) *Config { diff --git a/apiserver/internal/apis/label.go b/apiserver/internal/apis/label.go index 861a0c92..44d2cfc7 100644 --- a/apiserver/internal/apis/label.go +++ b/apiserver/internal/apis/label.go @@ -9,6 +9,7 @@ import ( lService "dkhalife.com/tasks/core/internal/services/labels" "dkhalife.com/tasks/core/internal/telemetry" auth "dkhalife.com/tasks/core/internal/utils/auth" + middleware "dkhalife.com/tasks/core/internal/utils/middleware" "github.com/gin-gonic/gin" ) @@ -87,7 +88,7 @@ func (h *LabelsAPIHandler) deleteLabel(c *gin.Context) { func LabelRoutes(r *gin.Engine, h *LabelsAPIHandler, authGate *authMW.AuthMiddleware) { labelRoutes := r.Group("api/v1/labels") - labelRoutes.Use(authGate.MiddlewareFunc()) + labelRoutes.Use(authGate.MiddlewareFunc(), middleware.DeletionGuardMiddleware()) { labelRoutes.GET("", authMW.ScopeMiddleware(models.ApiTokenScopeLabelRead), h.getLabels) labelRoutes.POST("", authMW.ScopeMiddleware(models.ApiTokenScopeLabelWrite), h.createLabel) diff --git a/apiserver/internal/apis/task.go b/apiserver/internal/apis/task.go index fb135cd4..717155eb 100644 --- a/apiserver/internal/apis/task.go +++ b/apiserver/internal/apis/task.go @@ -10,6 +10,7 @@ import ( tService "dkhalife.com/tasks/core/internal/services/tasks" "dkhalife.com/tasks/core/internal/telemetry" auth "dkhalife.com/tasks/core/internal/utils/auth" + middleware "dkhalife.com/tasks/core/internal/utils/middleware" "github.com/gin-gonic/gin" ) @@ -269,7 +270,7 @@ func (h *TasksAPIHandler) GetTaskHistory(c *gin.Context) { func TaskRoutes(router *gin.Engine, h *TasksAPIHandler, auth *authMW.AuthMiddleware) { tasksRoutes := router.Group("api/v1/tasks") - tasksRoutes.Use(auth.MiddlewareFunc()) + tasksRoutes.Use(auth.MiddlewareFunc(), middleware.DeletionGuardMiddleware()) { tasksRoutes.GET("/", authMW.ScopeMiddleware(models.ApiTokenScopeTaskRead), h.getTasks) tasksRoutes.GET("/due", authMW.ScopeMiddleware(models.ApiTokenScopeTaskRead), h.getTasksDueBefore) diff --git a/apiserver/internal/apis/user.go b/apiserver/internal/apis/user.go index a3cff2fd..63fb1a28 100644 --- a/apiserver/internal/apis/user.go +++ b/apiserver/internal/apis/user.go @@ -53,6 +53,16 @@ func (h *UsersAPIHandler) GetUserProfile(c *gin.Context) { currentIdentity := auth.CurrentIdentity(c) log := logging.FromContext(c) + user, err := h.userRepo.GetUser(c, currentIdentity.UserID) + if err != nil { + log.Errorf("failed to get user: %s", err.Error()) + telemetry.TrackError(c, "user_profile_get_failed", "user-handler", err, nil) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to get user profile", + }) + return + } + notificationSettings, err := h.nRepo.GetUserNotificationSettings(c, currentIdentity.UserID) if err != nil { log.Errorf("failed to get notification settings: %s", err.Error()) @@ -65,7 +75,8 @@ func (h *UsersAPIHandler) GetUserProfile(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "user": gin.H{ - "notifications": notificationSettings, + "notifications": notificationSettings, + "deletion_requested_at": user.DeletionRequestedAt, }, }) } @@ -86,12 +97,26 @@ func (h *UsersAPIHandler) UpdateNotificationSettings(c *gin.Context) { c.JSON(status, response) } +func (h *UsersAPIHandler) RequestDeletion(c *gin.Context) { + currentIdentity := auth.CurrentIdentity(c) + status, response := h.userService.RequestDeletion(c, currentIdentity.UserID) + c.JSON(status, response) +} + +func (h *UsersAPIHandler) CancelDeletion(c *gin.Context) { + currentIdentity := auth.CurrentIdentity(c) + status, response := h.userService.CancelDeletion(c, currentIdentity.UserID) + c.JSON(status, response) +} + func UserRoutes(router *gin.Engine, h *UsersAPIHandler, authMiddleware *authMW.AuthMiddleware, limiter *limiter.Limiter) { userRoutes := router.Group("api/v1/users") userRoutes.Use(authMiddleware.MiddlewareFunc(), middleware.RateLimitMiddleware(limiter)) { userRoutes.GET("/profile", authMW.ScopeMiddleware(models.ApiTokenScopeUserRead), h.GetUserProfile) - userRoutes.PUT("/notifications", authMW.ScopeMiddleware(models.ApiTokenScopeUserWrite), h.UpdateNotificationSettings) + userRoutes.PUT("/notifications", authMW.ScopeMiddleware(models.ApiTokenScopeUserWrite), middleware.DeletionGuardMiddleware(), h.UpdateNotificationSettings) + userRoutes.POST("/deletion", authMW.ScopeMiddleware(models.ApiTokenScopeUserWrite), h.RequestDeletion) + userRoutes.DELETE("/deletion", authMW.ScopeMiddleware(models.ApiTokenScopeUserWrite), h.CancelDeletion) } authRoutes := router.Group("api/v1/auth") diff --git a/apiserver/internal/middleware/auth/auth.go b/apiserver/internal/middleware/auth/auth.go index aec4eb15..82b81f80 100644 --- a/apiserver/internal/middleware/auth/auth.go +++ b/apiserver/internal/middleware/auth/auth.go @@ -142,9 +142,10 @@ func (m *AuthMiddleware) verifyAccessToken(ctx context.Context, rawToken string) } return &models.SignedInIdentity{ - UserID: user.ID, - Type: models.IdentityTypeUser, - Scopes: models.AllUserScopes(), + UserID: user.ID, + Type: models.IdentityTypeUser, + Scopes: models.AllUserScopes(), + PendingDeletion: user.DeletionRequestedAt != nil, }, nil } @@ -155,9 +156,10 @@ func (m *AuthMiddleware) bypassAuth(ctx context.Context) (*models.SignedInIdenti } return &models.SignedInIdentity{ - UserID: user.ID, - Type: models.IdentityTypeUser, - Scopes: models.AllUserScopes(), + UserID: user.ID, + Type: models.IdentityTypeUser, + Scopes: models.AllUserScopes(), + PendingDeletion: user.DeletionRequestedAt != nil, }, nil } diff --git a/apiserver/internal/migrations/006_account_deletion.go b/apiserver/internal/migrations/006_account_deletion.go new file mode 100644 index 00000000..1ec87a31 --- /dev/null +++ b/apiserver/internal/migrations/006_account_deletion.go @@ -0,0 +1,69 @@ +package migrations + +import ( + "context" + "fmt" + + "gorm.io/gorm" +) + +func init() { + Register(&AccountDeletionMigration{}) +} + +type AccountDeletionMigration struct{} + +func (m *AccountDeletionMigration) Version() int { + return 6 +} + +func (m *AccountDeletionMigration) Name() string { + return "account_deletion" +} + +func (m *AccountDeletionMigration) Up(ctx context.Context, db *gorm.DB) error { + dbCtx := db.WithContext(ctx) + dialect := db.Dialector.Name() + + switch dialect { + case "sqlite": + return dbCtx.Exec("ALTER TABLE users ADD COLUMN deletion_requested_at DATETIME DEFAULT NULL").Error + case "mysql": + return dbCtx.Exec("ALTER TABLE users ADD COLUMN deletion_requested_at DATETIME DEFAULT NULL").Error + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } +} + +func (m *AccountDeletionMigration) Down(ctx context.Context, db *gorm.DB) error { + dbCtx := db.WithContext(ctx) + dialect := db.Dialector.Name() + + switch dialect { + case "sqlite": + stmts := []string{ + `CREATE TABLE users_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + directory_id TEXT NOT NULL DEFAULT '', + object_id TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT NULL, + disabled BOOLEAN DEFAULT false + )`, + `INSERT INTO users_new (id, directory_id, object_id, created_at, updated_at, disabled) + SELECT id, directory_id, object_id, created_at, updated_at, disabled FROM users`, + `DROP TABLE users`, + `ALTER TABLE users_new RENAME TO users`, + } + for _, stmt := range stmts { + if err := dbCtx.Exec(stmt).Error; err != nil { + return err + } + } + return nil + case "mysql": + return dbCtx.Exec("ALTER TABLE users DROP COLUMN deletion_requested_at").Error + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } +} diff --git a/apiserver/internal/models/user.go b/apiserver/internal/models/user.go index 5df7d64b..359e78de 100644 --- a/apiserver/internal/models/user.go +++ b/apiserver/internal/models/user.go @@ -5,12 +5,13 @@ import ( ) type User struct { - ID int `json:"id" gorm:"primary_key;not null"` - DirectoryID string `json:"-" gorm:"column:directory_id;not null;default:''"` - ObjectID string `json:"-" gorm:"column:object_id;not null;default:''"` - CreatedAt time.Time `json:"-" gorm:"column:created_at;default:CURRENT_TIMESTAMP"` - UpdatedAt time.Time `json:"-" gorm:"column:updated_at;default:NULL;autoUpdateTime"` - Disabled bool `json:"-" gorm:"column:disabled;default:false"` + ID int `json:"id" gorm:"primary_key;not null"` + DirectoryID string `json:"-" gorm:"column:directory_id;not null;default:''"` + ObjectID string `json:"-" gorm:"column:object_id;not null;default:''"` + CreatedAt time.Time `json:"-" gorm:"column:created_at;default:CURRENT_TIMESTAMP"` + UpdatedAt time.Time `json:"-" gorm:"column:updated_at;default:NULL;autoUpdateTime"` + Disabled bool `json:"-" gorm:"column:disabled;default:false"` + DeletionRequestedAt *time.Time `json:"deletion_requested_at" gorm:"column:deletion_requested_at;default:NULL"` NotificationSettings NotificationSettings `json:"-" gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE;"` Labels []Label `json:"-" gorm:"foreignKey:CreatedBy;constraint:OnDelete:CASCADE;"` @@ -24,9 +25,10 @@ const ( ) type SignedInIdentity struct { - UserID int - Type IdentityType - Scopes []ApiTokenScope + UserID int + Type IdentityType + Scopes []ApiTokenScope + PendingDeletion bool } type ApiTokenScope string diff --git a/apiserver/internal/repos/user/user.go b/apiserver/internal/repos/user/user.go index 3de2b41a..5db9ab5f 100644 --- a/apiserver/internal/repos/user/user.go +++ b/apiserver/internal/repos/user/user.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "time" "dkhalife.com/tasks/core/config" "dkhalife.com/tasks/core/internal/models" @@ -11,6 +12,7 @@ import ( ) var ErrDisabledUser = errors.New("account is disabled") +var ErrPendingDeletion = errors.New("account is pending deletion") type IUserRepo interface { CreateUser(c context.Context, user *models.User) error @@ -20,6 +22,10 @@ type IUserRepo interface { UpdateNotificationSettings(c context.Context, userID int, provider models.NotificationProvider, triggers models.NotificationTriggerOptions) error DeleteNotificationsForUser(c context.Context, userID int) error GetLastCreatedOrModifiedForUserResources(c context.Context, userID int) (string, error) + RequestDeletion(c context.Context, userID int) error + CancelDeletion(c context.Context, userID int) error + FindUsersForDeletion(c context.Context, gracePeriod time.Duration) ([]models.User, error) + DeleteUser(c context.Context, userID int) error } type UserRepository struct { @@ -125,3 +131,25 @@ func (r *UserRepository) GetLastCreatedOrModifiedForUserResources(c context.Cont return result, err } + +func (r *UserRepository) RequestDeletion(c context.Context, userID int) error { + now := time.Now().UTC() + return r.db.WithContext(c).Model(&models.User{}).Where("id = ?", userID).Update("deletion_requested_at", now).Error +} + +func (r *UserRepository) CancelDeletion(c context.Context, userID int) error { + return r.db.WithContext(c).Model(&models.User{}).Where("id = ?", userID).Update("deletion_requested_at", nil).Error +} + +func (r *UserRepository) FindUsersForDeletion(c context.Context, gracePeriod time.Duration) ([]models.User, error) { + threshold := time.Now().UTC().Add(-gracePeriod) + var users []models.User + err := r.db.WithContext(c). + Where("deletion_requested_at IS NOT NULL AND deletion_requested_at <= ?", threshold). + Find(&users).Error + return users, err +} + +func (r *UserRepository) DeleteUser(c context.Context, userID int) error { + return r.db.WithContext(c).Delete(&models.User{}, userID).Error +} diff --git a/apiserver/internal/repos/user/user_test.go b/apiserver/internal/repos/user/user_test.go index 8040521d..c07d09d0 100644 --- a/apiserver/internal/repos/user/user_test.go +++ b/apiserver/internal/repos/user/user_test.go @@ -217,3 +217,224 @@ func (s *UserTestSuite) TestUpdateNotificationSettings() { s.True(updated.Triggers.DueDate) s.True(updated.Triggers.PreDue) } + +func (s *UserTestSuite) TestRequestDeletion() { + ctx := context.Background() + + user := &models.User{ + DirectoryID: "del-dir", + ObjectID: "del-obj", + CreatedAt: time.Now(), + } + s.Require().NoError(s.DB.Create(user).Error) + + before := time.Now() + err := s.repo.RequestDeletion(ctx, user.ID) + s.Require().NoError(err) + + var updated models.User + s.Require().NoError(s.DB.First(&updated, user.ID).Error) + s.Require().NotNil(updated.DeletionRequestedAt) + s.True(updated.DeletionRequestedAt.After(before) || updated.DeletionRequestedAt.Equal(before)) +} + +func (s *UserTestSuite) TestCancelDeletion() { + ctx := context.Background() + + now := time.Now() + user := &models.User{ + DirectoryID: "cancel-dir", + ObjectID: "cancel-obj", + CreatedAt: time.Now(), + DeletionRequestedAt: &now, + } + s.Require().NoError(s.DB.Create(user).Error) + + err := s.repo.CancelDeletion(ctx, user.ID) + s.Require().NoError(err) + + var updated models.User + s.Require().NoError(s.DB.First(&updated, user.ID).Error) + s.Nil(updated.DeletionRequestedAt) +} + +func (s *UserTestSuite) TestFindUsersForDeletion_ReturnsExpired() { + ctx := context.Background() + + past := time.Now().Add(-25 * time.Hour) + user := &models.User{ + DirectoryID: "expired-dir", + ObjectID: "expired-obj", + CreatedAt: time.Now(), + DeletionRequestedAt: &past, + } + s.Require().NoError(s.DB.Create(user).Error) + + users, err := s.repo.FindUsersForDeletion(ctx, 24*time.Hour) + s.Require().NoError(err) + s.Require().Len(users, 1) + s.Equal(user.ID, users[0].ID) +} + +func (s *UserTestSuite) TestFindUsersForDeletion_ExcludesWithinGracePeriod() { + ctx := context.Background() + + recent := time.Now().Add(-1 * time.Hour) + user := &models.User{ + DirectoryID: "recent-dir", + ObjectID: "recent-obj", + CreatedAt: time.Now(), + DeletionRequestedAt: &recent, + } + s.Require().NoError(s.DB.Create(user).Error) + + users, err := s.repo.FindUsersForDeletion(ctx, 24*time.Hour) + s.Require().NoError(err) + s.Empty(users) +} + +func (s *UserTestSuite) TestFindUsersForDeletion_ExcludesNoDeletionRequested() { + ctx := context.Background() + + user := &models.User{ + DirectoryID: "normal-dir", + ObjectID: "normal-obj", + CreatedAt: time.Now(), + } + s.Require().NoError(s.DB.Create(user).Error) + + users, err := s.repo.FindUsersForDeletion(ctx, 24*time.Hour) + s.Require().NoError(err) + s.Empty(users) +} + +func (s *UserTestSuite) TestDeleteUser() { + ctx := context.Background() + + user := &models.User{ + DirectoryID: "todelete-dir", + ObjectID: "todelete-obj", + CreatedAt: time.Now(), + } + s.Require().NoError(s.DB.Create(user).Error) + + err := s.repo.DeleteUser(ctx, user.ID) + s.Require().NoError(err) + + var count int64 + s.DB.Model(&models.User{}).Where("id = ?", user.ID).Count(&count) + s.Zero(count) +} + +// TestDeleteUser_CascadesAllUserData verifies that deleting a user removes all +// associated data from every user-owned table with no orphaned records left behind. +func (s *UserTestSuite) TestDeleteUser_CascadesAllUserData() { + ctx := context.Background() + + // Create user with default notification settings via repo so all FKs are consistent. + user := &models.User{ + DirectoryID: "cascade-dir", + ObjectID: "cascade-obj", + CreatedAt: time.Now(), + } + s.Require().NoError(s.repo.CreateUser(ctx, user)) + + // Create a label + label := &models.Label{ + Name: "test-label", + Color: "#ff0000", + CreatedBy: user.ID, + } + s.Require().NoError(s.DB.Create(label).Error) + + // Create a task linked to that label + dueDate := time.Now().Add(24 * time.Hour) + task := &models.Task{ + Title: "test-task", + CreatedBy: user.ID, + IsActive: true, + NextDueDate: &dueDate, + } + s.Require().NoError(s.DB.Create(task).Error) + + // Link task to label via task_labels join table + taskLabel := &models.TaskLabel{TaskID: task.ID, LabelID: label.ID} + s.Require().NoError(s.DB.Create(taskLabel).Error) + + // Create task history + completedDate := time.Now() + history := &models.TaskHistory{ + TaskID: task.ID, + CompletedDate: &completedDate, + DueDate: &dueDate, + } + s.Require().NoError(s.DB.Create(history).Error) + + // Create a notification linked to the task and user + notification := &models.Notification{ + TaskID: task.ID, + UserID: user.ID, + Text: "Task due soon", + Type: models.NotificationTypeDueDate, + ScheduledFor: time.Now().Add(1 * time.Hour), + } + s.Require().NoError(s.DB.Create(notification).Error) + + // Verify data exists before deletion + s.assertRowCount("users", user.ID, 1) + s.assertRowCount("labels", user.ID, 1) + s.assertTaskRowCount(task.ID, 1) + s.assertTaskLabelRowCount(task.ID, 1) + s.assertTaskHistoryRowCount(task.ID, 1) + s.assertNotificationUserRowCount(user.ID, 1) + s.assertNotificationSettingsRowCount(user.ID, 1) + + // Delete the user + s.Require().NoError(s.repo.DeleteUser(ctx, user.ID)) + + // Verify ALL user data is gone + s.assertRowCount("users", user.ID, 0) + s.assertRowCount("labels", user.ID, 0) + s.assertTaskRowCount(task.ID, 0) + s.assertTaskLabelRowCount(task.ID, 0) + s.assertTaskHistoryRowCount(task.ID, 0) + s.assertNotificationUserRowCount(user.ID, 0) + s.assertNotificationSettingsRowCount(user.ID, 0) +} + +func (s *UserTestSuite) assertRowCount(table string, userID int, expected int) { + var count int64 + s.DB.Table(table).Where("id = ?", userID).Count(&count) + s.Equal(int64(expected), count, "expected %d row(s) in %s for user %d", expected, table, userID) +} + +func (s *UserTestSuite) assertTaskRowCount(taskID, expected int) { + var count int64 + s.DB.Model(&models.Task{}).Where("id = ?", taskID).Count(&count) + s.Equal(int64(expected), count, "expected %d task row(s) for task %d", expected, taskID) +} + +func (s *UserTestSuite) assertTaskLabelRowCount(taskID, expected int) { + var count int64 + s.DB.Model(&models.TaskLabel{}).Where("task_id = ?", taskID).Count(&count) + s.Equal(int64(expected), count, "expected %d task_label row(s) for task %d", expected, taskID) +} + +func (s *UserTestSuite) assertTaskHistoryRowCount(taskID, expected int) { + var count int64 + s.DB.Model(&models.TaskHistory{}).Where("task_id = ?", taskID).Count(&count) + s.Equal(int64(expected), count, "expected %d task_history row(s) for task %d", expected, taskID) +} + +func (s *UserTestSuite) assertNotificationUserRowCount(userID, expected int) { + var count int64 + s.DB.Model(&models.Notification{}).Where("user_id = ?", userID).Count(&count) + s.Equal(int64(expected), count, "expected %d notification row(s) for user %d", expected, userID) +} + +func (s *UserTestSuite) assertNotificationSettingsRowCount(userID, expected int) { + var count int64 + s.DB.Model(&models.NotificationSettings{}).Where("user_id = ?", userID).Count(&count) + s.Equal(int64(expected), count, "expected %d notification_settings row(s) for user %d", expected, userID) +} + diff --git a/apiserver/internal/services/scheduler/scheduler.go b/apiserver/internal/services/scheduler/scheduler.go index 0f1fbf2e..09a61807 100644 --- a/apiserver/internal/services/scheduler/scheduler.go +++ b/apiserver/internal/services/scheduler/scheduler.go @@ -7,20 +7,23 @@ import ( "dkhalife.com/tasks/core/config" "dkhalife.com/tasks/core/internal/services/logging" "dkhalife.com/tasks/core/internal/services/notifications" + "dkhalife.com/tasks/core/internal/services/users" "dkhalife.com/tasks/core/internal/telemetry" ) type Scheduler struct { - stopChan chan bool - notifier *notifications.Notifier - config config.SchedulerConfig + stopChan chan bool + notifier *notifications.Notifier + userService *users.UserService + config config.SchedulerConfig } -func NewScheduler(cfg *config.Config, n *notifications.Notifier) *Scheduler { +func NewScheduler(cfg *config.Config, n *notifications.Notifier, us *users.UserService) *Scheduler { return &Scheduler{ - stopChan: make(chan bool), - notifier: n, - config: cfg.SchedulerJobs, + stopChan: make(chan bool), + notifier: n, + userService: us, + config: cfg.SchedulerJobs, } } @@ -31,6 +34,7 @@ func (s *Scheduler) Start(c context.Context) { go s.runScheduler(c, "NOTIFICATION_SCHEDULER", s.notifier.GenerateOverdueNotifications, s.config.OverdueFrequency) go s.runScheduler(c, "NOTIFICATION_SENDER", s.notifier.LoadAndSendNotificationJob, s.config.DueFrequency) go s.runScheduler(c, "NOTIFICATION_CLEANUP", s.notifier.CleanupNotifications, s.config.NotificationCleanup) + go s.runScheduler(c, "ACCOUNT_DELETION", s.userService.ProcessDeletions, s.config.AccountDeletionFrequency) } func (s *Scheduler) runScheduler(c context.Context, jobName string, job func(c context.Context) error, interval time.Duration) { diff --git a/apiserver/internal/services/users/user.go b/apiserver/internal/services/users/user.go index 27d0074f..a5e61906 100644 --- a/apiserver/internal/services/users/user.go +++ b/apiserver/internal/services/users/user.go @@ -3,6 +3,7 @@ package users import ( "context" "net/http" + "time" "dkhalife.com/tasks/core/internal/models" repos "dkhalife.com/tasks/core/internal/repos/user" @@ -12,6 +13,8 @@ import ( "github.com/gin-gonic/gin" ) +const deletionGracePeriod = 24 * time.Hour + type UserService struct { r repos.IUserRepo ws *ws.WSServer @@ -53,3 +56,61 @@ func (s *UserService) UpdateNotificationSettings(ctx context.Context, userID int return http.StatusNoContent, gin.H{} } + +func (s *UserService) RequestDeletion(ctx context.Context, userID int) (int, interface{}) { + log := logging.FromContext(ctx) + if err := s.r.RequestDeletion(ctx, userID); err != nil { + log.Errorf("failed to request account deletion: %s", err.Error()) + telemetry.TrackError(ctx, "account_deletion_request_failed", "user-service", err, nil) + return http.StatusInternalServerError, gin.H{ + "error": "Failed to request account deletion", + } + } + + s.ws.BroadcastToUser(userID, ws.WSResponse{ + Action: "account_deletion_requested", + Data: gin.H{}, + }) + + return http.StatusNoContent, gin.H{} +} + +func (s *UserService) CancelDeletion(ctx context.Context, userID int) (int, interface{}) { + log := logging.FromContext(ctx) + if err := s.r.CancelDeletion(ctx, userID); err != nil { + log.Errorf("failed to cancel account deletion: %s", err.Error()) + telemetry.TrackError(ctx, "account_deletion_cancel_failed", "user-service", err, nil) + return http.StatusInternalServerError, gin.H{ + "error": "Failed to cancel account deletion", + } + } + + s.ws.BroadcastToUser(userID, ws.WSResponse{ + Action: "account_deletion_cancelled", + Data: gin.H{}, + }) + + return http.StatusNoContent, gin.H{} +} + +func (s *UserService) ProcessDeletions(ctx context.Context) error { + log := logging.FromContext(ctx) + + users, err := s.r.FindUsersForDeletion(ctx, deletionGracePeriod) + if err != nil { + return err + } + + for _, user := range users { + log.Infof("deleting account for user %d (requested at %s)", user.ID, user.DeletionRequestedAt) + if err := s.r.DeleteUser(ctx, user.ID); err != nil { + log.Errorf("failed to delete user %d: %s", user.ID, err.Error()) + telemetry.TrackError(ctx, "account_deletion_failed", "user-service", err, map[string]string{ + "user_id": string(rune(user.ID)), + }) + } + } + + return nil +} + diff --git a/apiserver/internal/services/users/user_test.go b/apiserver/internal/services/users/user_test.go new file mode 100644 index 00000000..654dc860 --- /dev/null +++ b/apiserver/internal/services/users/user_test.go @@ -0,0 +1,119 @@ +package users + +import ( + "context" + "net/http" + "testing" + "time" + + "dkhalife.com/tasks/core/config" + "dkhalife.com/tasks/core/internal/models" + uRepo "dkhalife.com/tasks/core/internal/repos/user" + "dkhalife.com/tasks/core/internal/utils/test" + "dkhalife.com/tasks/core/internal/ws" + authMW "dkhalife.com/tasks/core/internal/middleware/auth" + lRepo "dkhalife.com/tasks/core/internal/repos/label" + tRepo "dkhalife.com/tasks/core/internal/repos/task" + "github.com/stretchr/testify/suite" +) + +type UserServiceTestSuite struct { + test.DatabaseTestSuite + repo uRepo.IUserRepo + service *UserService + wsServer *ws.WSServer +} + +func TestUserServiceTestSuite(t *testing.T) { + suite.Run(t, new(UserServiceTestSuite)) +} + +func (s *UserServiceTestSuite) SetupTest() { + s.DatabaseTestSuite.SetupTest() + + cfg := &config.Config{ + Server: config.ServerConfig{Registration: true}, + } + s.repo = uRepo.NewUserRepository(s.DB, cfg) + + authMiddleware, _ := authMW.NewAuthMiddleware(&config.Config{}, s.repo) + taskRepo := tRepo.NewTaskRepository(s.DB, cfg) + labelRepo := lRepo.NewLabelRepository(s.DB, cfg) + s.wsServer = ws.NewWSServer(authMiddleware, taskRepo, labelRepo, s.repo) + s.service = NewUserService(s.repo, s.wsServer) +} + +func (s *UserServiceTestSuite) createUser() *models.User { + user := &models.User{ + DirectoryID: "svc-dir", + ObjectID: "svc-obj", + CreatedAt: time.Now(), + } + s.Require().NoError(s.repo.CreateUser(context.Background(), user)) + return user +} + +func (s *UserServiceTestSuite) TestRequestDeletion_Success() { + user := s.createUser() + + status, _ := s.service.RequestDeletion(context.Background(), user.ID) + s.Equal(http.StatusNoContent, status) + + var updated models.User + s.Require().NoError(s.DB.First(&updated, user.ID).Error) + s.NotNil(updated.DeletionRequestedAt) +} + +func (s *UserServiceTestSuite) TestCancelDeletion_Success() { + user := s.createUser() + s.Require().NoError(s.repo.RequestDeletion(context.Background(), user.ID)) + + status, _ := s.service.CancelDeletion(context.Background(), user.ID) + s.Equal(http.StatusNoContent, status) + + var updated models.User + s.Require().NoError(s.DB.First(&updated, user.ID).Error) + s.Nil(updated.DeletionRequestedAt) +} + +func (s *UserServiceTestSuite) TestProcessDeletions_DeletesExpiredUsers() { + ctx := context.Background() + user := s.createUser() + + past := time.Now().Add(-25 * time.Hour) + s.Require().NoError(s.DB.Model(&models.User{}).Where("id = ?", user.ID).Update("deletion_requested_at", past).Error) + + err := s.service.ProcessDeletions(ctx) + s.Require().NoError(err) + + var count int64 + s.DB.Model(&models.User{}).Where("id = ?", user.ID).Count(&count) + s.Zero(count) +} + +func (s *UserServiceTestSuite) TestProcessDeletions_SkipsUsersWithinGracePeriod() { + ctx := context.Background() + user := s.createUser() + + recent := time.Now().Add(-1 * time.Hour) + s.Require().NoError(s.DB.Model(&models.User{}).Where("id = ?", user.ID).Update("deletion_requested_at", recent).Error) + + err := s.service.ProcessDeletions(ctx) + s.Require().NoError(err) + + var count int64 + s.DB.Model(&models.User{}).Where("id = ?", user.ID).Count(&count) + s.Equal(int64(1), count) +} + +func (s *UserServiceTestSuite) TestProcessDeletions_SkipsNormalUsers() { + ctx := context.Background() + user := s.createUser() + + err := s.service.ProcessDeletions(ctx) + s.Require().NoError(err) + + var count int64 + s.DB.Model(&models.User{}).Where("id = ?", user.ID).Count(&count) + s.Equal(int64(1), count) +} diff --git a/apiserver/internal/utils/database/database.go b/apiserver/internal/utils/database/database.go index c2f980f3..7983b619 100644 --- a/apiserver/internal/utils/database/database.go +++ b/apiserver/internal/utils/database/database.go @@ -86,6 +86,9 @@ func NewDatabase(cfg *config.Config) (*gorm.DB, error) { if err := db.Exec("PRAGMA busy_timeout=5000;").Error; err != nil { return nil, err } + if err := db.Exec("PRAGMA foreign_keys = ON;").Error; err != nil { + return nil, err + } } return db, nil diff --git a/apiserver/internal/utils/middleware/deletion_guard.go b/apiserver/internal/utils/middleware/deletion_guard.go new file mode 100644 index 00000000..a0488001 --- /dev/null +++ b/apiserver/internal/utils/middleware/deletion_guard.go @@ -0,0 +1,47 @@ +package middleware + +import ( + "net/http" + "strings" + + authUtils "dkhalife.com/tasks/core/internal/utils/auth" + "github.com/gin-gonic/gin" +) + +var deletionExemptPaths = map[string]struct{}{ + "/api/v1/users/deletion": {}, +} + +// DeletionGuardMiddleware blocks write operations for accounts that are pending deletion. +// Read-only methods (GET, HEAD, OPTIONS) are always permitted. The deletion management +// endpoints themselves are also exempt so users can cancel. +func DeletionGuardMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + method := c.Request.Method + if method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions { + c.Next() + return + } + + path := c.FullPath() + if path == "" { + path = c.Request.URL.Path + } + // Normalize: strip trailing slash for comparison + path = strings.TrimRight(path, "/") + if _, exempt := deletionExemptPaths[path]; exempt { + c.Next() + return + } + + identity := authUtils.CurrentIdentity(c) + if identity != nil && identity.PendingDeletion { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "Account is pending deletion", + }) + return + } + + c.Next() + } +} diff --git a/apiserver/internal/utils/middleware/deletion_guard_test.go b/apiserver/internal/utils/middleware/deletion_guard_test.go new file mode 100644 index 00000000..a2c8b11a --- /dev/null +++ b/apiserver/internal/utils/middleware/deletion_guard_test.go @@ -0,0 +1,105 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "dkhalife.com/tasks/core/internal/models" + authUtils "dkhalife.com/tasks/core/internal/utils/auth" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" +) + +type DeletionGuardTestSuite struct { + suite.Suite + router *gin.Engine +} + +func TestDeletionGuardTestSuite(t *testing.T) { + suite.Run(t, new(DeletionGuardTestSuite)) +} + +func (s *DeletionGuardTestSuite) SetupTest() { + gin.SetMode(gin.TestMode) + s.router = gin.New() +} + +func (s *DeletionGuardTestSuite) injectIdentity(pendingDeletion bool) gin.HandlerFunc { + return func(c *gin.Context) { + c.Set(authUtils.IdentityKey, &models.SignedInIdentity{ + UserID: 1, + Type: models.IdentityTypeUser, + Scopes: models.AllUserScopes(), + PendingDeletion: pendingDeletion, + }) + c.Next() + } +} + +func (s *DeletionGuardTestSuite) TestWriteBlockedWhenPendingDeletion() { + s.router.Use(s.injectIdentity(true), DeletionGuardMiddleware()) + s.router.POST("/api/v1/tasks/", func(c *gin.Context) { + c.Status(http.StatusCreated) + }) + + for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} { + w := httptest.NewRecorder() + req, _ := http.NewRequest(method, "/api/v1/tasks/", nil) + s.router.ServeHTTP(w, req) + s.Equal(http.StatusForbidden, w.Code, "expected 403 for %s when pending deletion", method) + } +} + +func (s *DeletionGuardTestSuite) TestReadAllowedWhenPendingDeletion() { + s.router.Use(s.injectIdentity(true), DeletionGuardMiddleware()) + s.router.GET("/api/v1/tasks/", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/tasks/", nil) + s.router.ServeHTTP(w, req) + s.Equal(http.StatusOK, w.Code) +} + +func (s *DeletionGuardTestSuite) TestWriteAllowedWhenNotPendingDeletion() { + s.router.Use(s.injectIdentity(false), DeletionGuardMiddleware()) + s.router.POST("/api/v1/tasks/", func(c *gin.Context) { + c.Status(http.StatusCreated) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/tasks/", nil) + s.router.ServeHTTP(w, req) + s.Equal(http.StatusCreated, w.Code) +} + +func (s *DeletionGuardTestSuite) TestDeletionEndpointExemptedFromBlock() { + s.router.Use(s.injectIdentity(true), DeletionGuardMiddleware()) + s.router.POST("/api/v1/users/deletion", func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + s.router.DELETE("/api/v1/users/deletion", func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + + for _, method := range []string{http.MethodPost, http.MethodDelete} { + w := httptest.NewRecorder() + req, _ := http.NewRequest(method, "/api/v1/users/deletion", nil) + s.router.ServeHTTP(w, req) + s.Equal(http.StatusNoContent, w.Code, "deletion endpoint should be exempt for method %s", method) + } +} + +func (s *DeletionGuardTestSuite) TestNoIdentityAllowsPassThrough() { + s.router.Use(DeletionGuardMiddleware()) + s.router.POST("/api/v1/tasks/", func(c *gin.Context) { + c.Status(http.StatusCreated) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/tasks/", nil) + s.router.ServeHTTP(w, req) + s.Equal(http.StatusCreated, w.Code) +} diff --git a/apiserver/internal/ws/server.go b/apiserver/internal/ws/server.go index fc0317d7..7efdcebc 100644 --- a/apiserver/internal/ws/server.go +++ b/apiserver/internal/ws/server.go @@ -226,6 +226,16 @@ func Routes(router *gin.Engine, s *WSServer) { router.GET("/ws", s.HandleConnection) } +// wsReadOnlyActions contains WS actions that only read data and are always permitted, +// including for accounts with pending deletion. +var wsReadOnlyActions = map[string]struct{}{ + "get_tasks": {}, + "get_completed_tasks": {}, + "get_task": {}, + "get_task_history": {}, + "get_user_labels": {}, +} + func (s *WSServer) handleMessage(ctx context.Context, conn *connection, msg WSMessage) { s.mu.RLock() handler, ok := s.handlers[msg.Action] @@ -239,6 +249,21 @@ func (s *WSServer) handleMessage(ctx context.Context, conn *connection, msg WSMe return } + if conn.identity.PendingDeletion { + if _, readOnly := wsReadOnlyActions[msg.Action]; !readOnly { + resp := &WSResponse{ + Action: msg.Action, + RequestID: msg.RequestID, + Status: http.StatusForbidden, + Data: map[string]string{"error": "Account is pending deletion"}, + } + if err := conn.safeWriteJSON(resp); err != nil { + log.Errorf("failed to write JSON to WebSocket: %v", err) + } + return + } + } + resp := handler(ctx, conn.identity.UserID, msg) if resp == nil { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e38f27fc..879ee448 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import { AppDispatch, store } from './store/store' import { connect } from 'react-redux' import { fetchUser } from './store/userSlice' import { StatusList } from './components/StatusList' +import { DeletionBannerWrapper } from './components/DeletionBanner' import { fetchTasks, initGroups } from './store/tasksSlice' import { FIVE_MINUTES_MS } from '@/constants/time' @@ -125,6 +126,7 @@ class AppImpl extends React.Component { + diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index 314d6751..386656d4 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -25,3 +25,9 @@ export const UpdateNotificationSettings = async ( }), ws: (ws) => ws.request('update_notification_settings', { provider, triggers }), }) + +export const RequestAccountDeletion = async () => + await Request(`/users/deletion`, 'POST') + +export const CancelAccountDeletion = async () => + await Request(`/users/deletion`, 'DELETE') diff --git a/frontend/src/components/DeletionBanner.tsx b/frontend/src/components/DeletionBanner.tsx new file mode 100644 index 00000000..6915154d --- /dev/null +++ b/frontend/src/components/DeletionBanner.tsx @@ -0,0 +1,78 @@ +import React from 'react' +import { Alert, Button, Typography } from '@mui/joy' +import { WarningAmberRounded } from '@mui/icons-material' +import { connect } from 'react-redux' +import { AppDispatch, RootState } from '@/store/store' +import { cancelAccountDeletion } from '@/store/userSlice' +import { NavigationPaths } from '@/utils/navigation' +import { useNavigate } from 'react-router-dom' + +type DeletionBannerProps = { + deletionRequestedAt: string | null + deletionStatus: 'loading' | 'succeeded' | 'failed' | null + cancelDeletion: () => void +} + +class DeletionBannerImpl extends React.Component { + private formatDeletionTime(iso: string): string { + const requested = new Date(iso) + const deleteAt = new Date(requested.getTime() + 24 * 60 * 60 * 1000) + return deleteAt.toLocaleString() + } + + render(): React.ReactNode { + const { deletionRequestedAt, deletionStatus, cancelDeletion } = this.props + if (!deletionRequestedAt) return null + + return ( + } + endDecorator={ + + } + sx={{ borderRadius: 0 }} + > + + Your account is scheduled for deletion on{' '} + {this.formatDeletionTime(deletionRequestedAt)}. + Writes are disabled. Visit{' '} + + Settings + {' '} + to manage this. + + + ) + } +} + +const mapStateToProps = (state: RootState) => ({ + deletionRequestedAt: state.user.profile.deletion_requested_at, + deletionStatus: state.user.deletionStatus, +}) + +const mapDispatchToProps = (dispatch: AppDispatch) => ({ + cancelDeletion: () => dispatch(cancelAccountDeletion()), +}) + +export const DeletionBanner = connect( + mapStateToProps, + mapDispatchToProps, +)(DeletionBannerImpl) + +// Functional wrapper needed to access useNavigate hook +export const DeletionBannerWrapper: React.FC = () => { + const navigate = useNavigate() + void navigate // referenced to avoid unused warning + return +} diff --git a/frontend/src/models/user.ts b/frontend/src/models/user.ts index 785ed251..d9331e13 100644 --- a/frontend/src/models/user.ts +++ b/frontend/src/models/user.ts @@ -8,4 +8,5 @@ export interface User { provider: NotificationType triggers: NotificationTriggerOptions } + deletion_requested_at: string | null } diff --git a/frontend/src/models/websocket.ts b/frontend/src/models/websocket.ts index 4872dc9d..f6886e55 100644 --- a/frontend/src/models/websocket.ts +++ b/frontend/src/models/websocket.ts @@ -65,6 +65,8 @@ export type WSEvent = | 'task_uncompleted' | 'task_skipped' | 'notification' + | 'account_deletion_requested' + | 'account_deletion_cancelled' export interface WSEventPayloads { label_created: { label: Label } @@ -81,6 +83,8 @@ export interface WSEventPayloads { task_uncompleted: Task task_skipped: Task notification: { task_id: number, type: NotificationTrigger } + account_deletion_requested: Record + account_deletion_cancelled: Record } export interface WSRequest { diff --git a/frontend/src/store/userSlice.ts b/frontend/src/store/userSlice.ts index 00c4ca0f..eb4dc9b9 100644 --- a/frontend/src/store/userSlice.ts +++ b/frontend/src/store/userSlice.ts @@ -1,5 +1,10 @@ import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit' -import { GetUserProfile, UpdateNotificationSettings } from '@/api/users' +import { + GetUserProfile, + UpdateNotificationSettings, + RequestAccountDeletion, + CancelAccountDeletion, +} from '@/api/users' import { User } from '@/models/user' import { NotificationTriggerOptions, NotificationType } from '@/models/notifications' import { SyncState } from '@/models/sync' @@ -16,6 +21,8 @@ export interface UserState { provider: NotificationType triggers: NotificationTriggerOptions } + deletionStatus: SyncState | null + deletionError: string | null } const initialState: UserState = { @@ -30,6 +37,7 @@ const initialState: UserState = { overdue: false, }, }, + deletion_requested_at: null, }, status: 'loading', lastFetched: null, @@ -44,6 +52,8 @@ const initialState: UserState = { overdue: false, }, }, + deletionStatus: null, + deletionError: null, } export const fetchUser = createAsyncThunk('user/fetchUser', async () => { @@ -55,6 +65,21 @@ export const updateNotificationSettings = createAsyncThunk( 'user/updateNotificationSettings', async (settings: { type: NotificationType, options: NotificationTriggerOptions}) => await UpdateNotificationSettings(settings.type, settings.options)) +export const requestAccountDeletion = createAsyncThunk( + 'user/requestAccountDeletion', + async () => { + await RequestAccountDeletion() + return new Date().toISOString() + }, +) + +export const cancelAccountDeletion = createAsyncThunk( + 'user/cancelAccountDeletion', + async () => { + await CancelAccountDeletion() + }, +) + const userSlice = createSlice({ name: 'user', initialState, @@ -82,6 +107,12 @@ const userSlice = createSlice({ state.draftNotificationSettings.provider = action.payload.provider state.draftNotificationSettings.triggers = action.payload.triggers }, + accountDeletionRequested(state) { + state.profile.deletion_requested_at = new Date().toISOString() + }, + accountDeletionCancelled(state) { + state.profile.deletion_requested_at = null + }, }, extraReducers: builder => { builder @@ -122,6 +153,30 @@ const userSlice = createSlice({ action.error.message ?? 'An unknown error occurred while updating notification settings.' }) + .addCase(requestAccountDeletion.pending, state => { + state.deletionStatus = 'loading' + state.deletionError = null + }) + .addCase(requestAccountDeletion.fulfilled, (state, action) => { + state.deletionStatus = 'succeeded' + state.profile.deletion_requested_at = action.payload + }) + .addCase(requestAccountDeletion.rejected, (state, action) => { + state.deletionStatus = 'failed' + state.deletionError = action.error.message ?? 'Failed to request account deletion.' + }) + .addCase(cancelAccountDeletion.pending, state => { + state.deletionStatus = 'loading' + state.deletionError = null + }) + .addCase(cancelAccountDeletion.fulfilled, state => { + state.deletionStatus = 'succeeded' + state.profile.deletion_requested_at = null + }) + .addCase(cancelAccountDeletion.rejected, (state, action) => { + state.deletionStatus = 'failed' + state.deletionError = action.error.message ?? 'Failed to cancel account deletion.' + }) }, }) @@ -129,7 +184,11 @@ export const { setNotificationSettingsDraft } = userSlice.actions export const userReducer = userSlice.reducer -const { notificationSettingsUpdated } = userSlice.actions +const { + notificationSettingsUpdated, + accountDeletionRequested, + accountDeletionCancelled, +} = userSlice.actions const onNotificationSettingsUpdated = ( data: WSEventPayloads['notification_settings_updated'], @@ -137,10 +196,23 @@ const onNotificationSettingsUpdated = ( store.dispatch(notificationSettingsUpdated(data)) } +const onAccountDeletionRequested = () => { + store.dispatch(accountDeletionRequested()) +} + +const onAccountDeletionCancelled = () => { + store.dispatch(accountDeletionCancelled()) +} + export const registerWebSocketListeners = (ws: WebSocketManager) => { ws.on('notification_settings_updated', onNotificationSettingsUpdated) + ws.on('account_deletion_requested', onAccountDeletionRequested) + ws.on('account_deletion_cancelled', onAccountDeletionCancelled) } export const unregisterWebSocketListeners = (ws: WebSocketManager) => { ws.off('notification_settings_updated', onNotificationSettingsUpdated) + ws.off('account_deletion_requested', onAccountDeletionRequested) + ws.off('account_deletion_cancelled', onAccountDeletionCancelled) } + diff --git a/frontend/src/views/Settings/AccountDeletion.tsx b/frontend/src/views/Settings/AccountDeletion.tsx new file mode 100644 index 00000000..648a62e2 --- /dev/null +++ b/frontend/src/views/Settings/AccountDeletion.tsx @@ -0,0 +1,162 @@ +import React from 'react' +import { + Box, + Typography, + Divider, + Button, + Modal, + ModalDialog, + ModalClose, + DialogTitle, + DialogContent, + DialogActions, + Alert, +} from '@mui/joy' +import { WarningAmberRounded } from '@mui/icons-material' +import { connect } from 'react-redux' +import { AppDispatch, RootState } from '@/store/store' +import { + requestAccountDeletion, + cancelAccountDeletion, +} from '@/store/userSlice' + +type AccountDeletionProps = { + deletionRequestedAt: string | null + deletionStatus: 'loading' | 'succeeded' | 'failed' | null + deletionError: string | null + requestDeletion: () => void + cancelDeletion: () => void +} + +type AccountDeletionState = { + confirmOpen: boolean +} + +class AccountDeletionImpl extends React.Component< + AccountDeletionProps, + AccountDeletionState +> { + constructor(props: AccountDeletionProps) { + super(props) + this.state = { confirmOpen: false } + } + + private openConfirm = () => this.setState({ confirmOpen: true }) + private closeConfirm = () => this.setState({ confirmOpen: false }) + + private onConfirmDelete = () => { + this.closeConfirm() + this.props.requestDeletion() + } + + private formatDeletionTime(iso: string): string { + const requested = new Date(iso) + const deleteAt = new Date(requested.getTime() + 24 * 60 * 60 * 1000) + return deleteAt.toLocaleString() + } + + render(): React.ReactNode { + const { deletionRequestedAt, deletionStatus, deletionError, cancelDeletion } = + this.props + const { confirmOpen } = this.state + const isPending = !!deletionRequestedAt + const isLoading = deletionStatus === 'loading' + + return ( + + + Account Deletion + + + + {deletionError && ( + + {deletionError} + + )} + + {isPending ? ( + + } + sx={{ mb: 1 }} + > + Your account is scheduled for permanent deletion on{' '} + {this.formatDeletionTime(deletionRequestedAt!)}. + All your tasks, labels, and data will be permanently erased. You + can cancel below to restore full access. + + + + ) : ( + + + Permanently delete your account and all associated data. This + action initiates a 24-hour grace period during which you can + cancel. After 24 hours, all data will be irrecoverably deleted. + + + + )} + + + + + + + Confirm Account Deletion + + + Are you sure you want to delete your account? Your account will be + locked immediately and all data will be permanently deleted after + 24 hours. You can cancel during this period. + + + + + + + + + ) + } +} + +const mapStateToProps = (state: RootState) => ({ + deletionRequestedAt: state.user.profile.deletion_requested_at, + deletionStatus: state.user.deletionStatus, + deletionError: state.user.deletionError, +}) + +const mapDispatchToProps = (dispatch: AppDispatch) => ({ + requestDeletion: () => dispatch(requestAccountDeletion()), + cancelDeletion: () => dispatch(cancelAccountDeletion()), +}) + +export const AccountDeletion = connect( + mapStateToProps, + mapDispatchToProps, +)(AccountDeletionImpl) diff --git a/frontend/src/views/Settings/Settings.tsx b/frontend/src/views/Settings/Settings.tsx index d468ee9e..cb13d9c0 100644 --- a/frontend/src/views/Settings/Settings.tsx +++ b/frontend/src/views/Settings/Settings.tsx @@ -11,6 +11,7 @@ import { NotificationSettings } from '../Notifications/NotificationSettings' import { ThemeToggle } from './ThemeToggle' import { FeatureFlagSettings } from './FeatureFlagSettings' import { DesktopNotificationToggle } from './DesktopNotificationToggle' +import { AccountDeletion } from './AccountDeletion' import { storeValue } from '@/utils/storage' import { getHomeView, HomeView } from '@/utils/navigation' import { SelectValue } from '@mui/base' @@ -90,6 +91,7 @@ export class Settings extends React.Component { + ) } From 225254dba5cf9eef189d8a8bce459a147351aa6f Mon Sep 17 00:00:00 2001 From: Dany Khalife Date: Sat, 28 Mar 2026 19:12:19 -0700 Subject: [PATCH 2/2] copilot feedback --- .../com/dkhalife/tasks/ui/screen/TaskListScreen.kt | 3 ++- apiserver/internal/repos/user/user.go | 1 - apiserver/internal/repos/user/user_test.go | 6 +++++- apiserver/internal/services/users/user.go | 7 +++++-- apiserver/internal/ws/server.go | 13 +++++++++++++ frontend/src/App.tsx | 4 ++-- frontend/src/components/DeletionBanner.tsx | 13 +++---------- 7 files changed, 30 insertions(+), 17 deletions(-) diff --git a/android/app/src/main/java/com/dkhalife/tasks/ui/screen/TaskListScreen.kt b/android/app/src/main/java/com/dkhalife/tasks/ui/screen/TaskListScreen.kt index 7dfbf15d..3f39f0f5 100644 --- a/android/app/src/main/java/com/dkhalife/tasks/ui/screen/TaskListScreen.kt +++ b/android/app/src/main/java/com/dkhalife/tasks/ui/screen/TaskListScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -107,7 +108,7 @@ fun TaskListScreen( tint = MaterialTheme.colorScheme.onErrorContainer, modifier = Modifier.size(18.dp) ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.width(8.dp)) Text( text = stringResource(R.string.settings_section_account_deletion), style = MaterialTheme.typography.bodySmall, diff --git a/apiserver/internal/repos/user/user.go b/apiserver/internal/repos/user/user.go index 5db9ab5f..c9e05363 100644 --- a/apiserver/internal/repos/user/user.go +++ b/apiserver/internal/repos/user/user.go @@ -12,7 +12,6 @@ import ( ) var ErrDisabledUser = errors.New("account is disabled") -var ErrPendingDeletion = errors.New("account is pending deletion") type IUserRepo interface { CreateUser(c context.Context, user *models.User) error diff --git a/apiserver/internal/repos/user/user_test.go b/apiserver/internal/repos/user/user_test.go index c07d09d0..0fffe77b 100644 --- a/apiserver/internal/repos/user/user_test.go +++ b/apiserver/internal/repos/user/user_test.go @@ -404,7 +404,11 @@ func (s *UserTestSuite) TestDeleteUser_CascadesAllUserData() { func (s *UserTestSuite) assertRowCount(table string, userID int, expected int) { var count int64 - s.DB.Table(table).Where("id = ?", userID).Count(&count) + column := "id" + if table == "labels" { + column = "created_by" + } + s.DB.Table(table).Where(column+" = ?", userID).Count(&count) s.Equal(int64(expected), count, "expected %d row(s) in %s for user %d", expected, table, userID) } diff --git a/apiserver/internal/services/users/user.go b/apiserver/internal/services/users/user.go index a5e61906..da7be85d 100644 --- a/apiserver/internal/services/users/user.go +++ b/apiserver/internal/services/users/user.go @@ -2,6 +2,7 @@ package users import ( "context" + "fmt" "net/http" "time" @@ -67,6 +68,7 @@ func (s *UserService) RequestDeletion(ctx context.Context, userID int) (int, int } } + s.ws.SetPendingDeletionForUser(userID, true) s.ws.BroadcastToUser(userID, ws.WSResponse{ Action: "account_deletion_requested", Data: gin.H{}, @@ -85,6 +87,7 @@ func (s *UserService) CancelDeletion(ctx context.Context, userID int) (int, inte } } + s.ws.SetPendingDeletionForUser(userID, false) s.ws.BroadcastToUser(userID, ws.WSResponse{ Action: "account_deletion_cancelled", Data: gin.H{}, @@ -102,11 +105,11 @@ func (s *UserService) ProcessDeletions(ctx context.Context) error { } for _, user := range users { - log.Infof("deleting account for user %d (requested at %s)", user.ID, user.DeletionRequestedAt) + log.Infof("deleting account for user %d (requested at %v)", user.ID, user.DeletionRequestedAt) if err := s.r.DeleteUser(ctx, user.ID); err != nil { log.Errorf("failed to delete user %d: %s", user.ID, err.Error()) telemetry.TrackError(ctx, "account_deletion_failed", "user-service", err, map[string]string{ - "user_id": string(rune(user.ID)), + "user_id": fmt.Sprint(user.ID), }) } } diff --git a/apiserver/internal/ws/server.go b/apiserver/internal/ws/server.go index 7efdcebc..83e807dc 100644 --- a/apiserver/internal/ws/server.go +++ b/apiserver/internal/ws/server.go @@ -296,3 +296,16 @@ func (s *WSServer) BroadcastToUser(userID int, resp WSResponse) { } }() } + +// SetPendingDeletionForUser updates the PendingDeletion flag on all active +// connections for a user so the WS write-guard reflects current deletion state +// without requiring a reconnect. +func (s *WSServer) SetPendingDeletionForUser(userID int, pending bool) { + s.mu.RLock() + conns := s.userConnections[userID] + s.mu.RUnlock() + + for _, c := range conns { + c.identity.PendingDeletion = pending + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 879ee448..0397f114 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,7 +11,7 @@ import { AppDispatch, store } from './store/store' import { connect } from 'react-redux' import { fetchUser } from './store/userSlice' import { StatusList } from './components/StatusList' -import { DeletionBannerWrapper } from './components/DeletionBanner' +import { DeletionBanner } from './components/DeletionBanner' import { fetchTasks, initGroups } from './store/tasksSlice' import { FIVE_MINUTES_MS } from '@/constants/time' @@ -126,7 +126,7 @@ class AppImpl extends React.Component { - + diff --git a/frontend/src/components/DeletionBanner.tsx b/frontend/src/components/DeletionBanner.tsx index 6915154d..4c9af9c0 100644 --- a/frontend/src/components/DeletionBanner.tsx +++ b/frontend/src/components/DeletionBanner.tsx @@ -2,10 +2,10 @@ import React from 'react' import { Alert, Button, Typography } from '@mui/joy' import { WarningAmberRounded } from '@mui/icons-material' import { connect } from 'react-redux' +import { Link } from 'react-router-dom' import { AppDispatch, RootState } from '@/store/store' import { cancelAccountDeletion } from '@/store/userSlice' import { NavigationPaths } from '@/utils/navigation' -import { useNavigate } from 'react-router-dom' type DeletionBannerProps = { deletionRequestedAt: string | null @@ -46,9 +46,9 @@ class DeletionBannerImpl extends React.Component { Your account is scheduled for deletion on{' '} {this.formatDeletionTime(deletionRequestedAt)}. Writes are disabled. Visit{' '} - + Settings - {' '} + {' '} to manage this. @@ -69,10 +69,3 @@ export const DeletionBanner = connect( mapStateToProps, mapDispatchToProps, )(DeletionBannerImpl) - -// Functional wrapper needed to access useNavigate hook -export const DeletionBannerWrapper: React.FC = () => { - const navigate = useNavigate() - void navigate // referenced to avoid unused warning - return -}