feat: Implement account deletion (mark for removal) feature
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 23s

- Added `marked_for_deletion` and `marked_for_deletion_at` fields to User model (Python and TypeScript) with serialization updates
- Created POST /api/user/mark-for-deletion endpoint with JWT auth, error handling, and SSE event trigger
- Blocked login and password reset for marked users; added new error codes ACCOUNT_MARKED_FOR_DELETION and ALREADY_MARKED
- Updated UserProfile.vue with "Delete My Account" button, confirmation modal (email input), loading state, success/error modals, and sign-out/redirect logic
- Synced error codes and model fields between backend and frontend
- Added and updated backend and frontend tests to cover all flows and edge cases
- All Acceptance Criteria from the spec are complete and verified
This commit is contained in:
2026-02-06 16:19:08 -05:00
parent 47541afbbf
commit 0d651129cb
20 changed files with 1054 additions and 18 deletions

View File

@@ -0,0 +1,251 @@
# Feature: Account Deletion (Mark for Removal)
## Overview
**Goal:** Allow users to mark their account for deletion from the Profile page.
**User Story:**
As a user, I want to delete my account from the Profile page. When I click "Delete My Account", I want a confirmation dialog that warns me about data loss. After confirming by entering my email, my account will be marked for deletion, I will be signed out, and I will not be able to log in again.
---
## Data Model Changes
### Backend Model (`backend/models/user.py`)
Add the following fields to the `User` class:
```python
marked_for_deletion: bool = False
marked_for_deletion_at: datetime | None = None
```
- Update `to_dict()` and `from_dict()` methods to serialize these fields.
- Import `datetime` from Python standard library if not already imported.
### Frontend Model (`frontend/vue-app/src/common/models.ts`)
Add matching fields to the `User` interface:
```typescript
marked_for_deletion: boolean;
marked_for_deletion_at: string | null;
```
---
## Backend Implementation
### New Error Codes (`backend/api/error_codes.py`)
Add the following error code:
```python
ACCOUNT_MARKED_FOR_DELETION = 'ACCOUNT_MARKED_FOR_DELETION'
ALREADY_MARKED = 'ALREADY_MARKED'
```
### New API Endpoint (`backend/api/user_api.py`)
**Endpoint:** `POST /api/user/mark-for-deletion`
**Authentication:** Requires valid JWT (authenticated user).
**Request:**
```json
{}
```
(Empty body; user is identified from JWT token)
**Response:**
- **Success (200):**
```json
{ "success": true }
```
- **Error (400/401/403):**
```json
{ "error": "Error message", "code": "INVALID_USER" | "ALREADY_MARKED" }
```
**Logic:**
1. Extract current user from JWT token.
2. Validate user exists in database.
3. Check if already marked for deletion:
- If `marked_for_deletion == True`, return error with code `ALREADY_MARKED` (or make idempotent and return success).
4. Set `marked_for_deletion = True` and `marked_for_deletion_at = datetime.now(timezone.utc)`.
5. Save user to database using `users_db.update()`.
6. Trigger SSE event: `send_event_for_current_user('user_marked_for_deletion', { 'user_id': user.id })`.
7. Return success response.
### Login Blocking (`backend/api/auth_api.py`)
In the `/api/login` endpoint, after validating credentials:
1. Check if `user.marked_for_deletion == True`.
2. If yes, return:
```json
{
"error": "This account has been marked for deletion and cannot be accessed.",
"code": "ACCOUNT_MARKED_FOR_DELETION"
}
```
with HTTP status `403`.
### Password Reset Blocking (`backend/api/user_api.py`)
In the `/api/user/request-reset` endpoint:
1. After finding the user by email, check if `user.marked_for_deletion == True`.
2. If yes, **silently ignore the request**:
- Do not send an email.
- Return success response (to avoid leaking account status).
### SSE Event (`backend/events/types/event_types.py`)
Add new event type:
```python
USER_MARKED_FOR_DELETION = 'user_marked_for_deletion'
```
---
## Frontend Implementation
### Files Affected
- `frontend/vue-app/src/components/parent/UserProfile.vue`
- `frontend/vue-app/src/common/models.ts`
- `frontend/vue-app/src/common/errorCodes.ts`
### Error Codes (`frontend/vue-app/src/common/errorCodes.ts`)
Add:
```typescript
export const ACCOUNT_MARKED_FOR_DELETION = "ACCOUNT_MARKED_FOR_DELETION";
export const ALREADY_MARKED = "ALREADY_MARKED";
```
### UI Components (`UserProfile.vue`)
#### 1. Delete Account Button
- **Label:** "Delete My Account"
- **Style:** `.btn-danger-link` (use `--danger` color from `colors.css`)
- **Placement:** Below "Change Password" link, with `24px` margin-top
- **Behavior:** Opens warning modal on click
#### 2. Warning Modal (uses `ModalDialog.vue`)
- **Title:** "Delete Your Account?"
- **Body:**
"This will permanently delete your account and all associated data. This action cannot be undone."
- **Email Confirmation Input:**
- Require user to type their email address to confirm.
- Display message: "Type your email address to confirm:"
- Input field with `v-model` bound to `confirmEmail` ref.
- **Buttons:**
- **"Cancel"** (`.btn-secondary`) — closes modal
- **"Delete My Account"** (`.btn-danger`) — disabled until `confirmEmail` matches user email, triggers API call
#### 3. Loading State
- Disable "Delete My Account" button during API call.
- Show loading spinner or "Deleting..." text.
#### 4. Success Modal
- **Title:** "Account Deleted"
- **Body:**
"Your account has been marked for deletion. You will now be signed out."
- **Button:** "OK" (closes modal, triggers `logoutUser()` and redirects to `/auth/login`)
#### 5. Error Modal
- **Title:** "Error"
- **Body:** Display error message from API using `parseErrorResponse(res).msg`.
- **Button:** "Close"
### Frontend Logic
1. User clicks "Delete My Account" button.
2. Warning modal opens with email confirmation input.
3. User types email and clicks "Delete My Account".
4. Frontend calls `POST /api/user/mark-for-deletion`.
5. On success:
- Close warning modal.
- Show success modal.
- On "OK" click: call `logoutUser()` from `stores/auth.ts`, redirect to `/auth/login`.
6. On error:
- Close warning modal.
- Show error modal with message from API.
---
## Testing
### Backend Tests (`backend/tests/test_user_api.py`)
- [x] Test marking a valid user account (200, `marked_for_deletion = True`, `marked_for_deletion_at` is set).
- [x] Test marking an already-marked account (return error with `ALREADY_MARKED` or be idempotent).
- [x] Test marking with invalid JWT (401).
- [x] Test marking with missing JWT (401).
- [x] Test login attempt by marked user (403, `ACCOUNT_MARKED_FOR_DELETION`).
- [x] Test password reset request by marked user (silently ignored, returns 200 but no email sent).
- [x] Test SSE event is triggered after marking.
### Frontend Tests (`frontend/vue-app/src/components/__tests__/UserProfile.spec.ts`)
- [x] Test "Delete My Account" button renders.
- [x] Test warning modal opens on button click.
- [x] Test "Delete My Account" button in modal is disabled until email matches.
- [x] Test API call is made when user confirms with correct email.
- [x] Test success modal shows after successful API response.
- [x] Test error modal shows on API failure (with error message).
- [x] Test user is signed out after success (calls `logoutUser()`).
- [x] Test redirect to login page after sign-out.
- [x] Test button is disabled during loading.
---
## Future Considerations
- A background scheduler will be implemented to physically delete marked accounts after a grace period (e.g., 30 days).
- Admin panel to view and manage marked accounts.
- Email notification to user when account is marked for deletion (with grace period details).
---
## Acceptance Criteria (Definition of Done)
### Data Model
- [x] Add `marked_for_deletion` and `marked_for_deletion_at` fields to `User` model (backend).
- [x] Add matching fields to `User` interface (frontend).
- [x] Update `to_dict()` and `from_dict()` methods in `User` model.
### Backend
- [x] Create `POST /api/user/mark-for-deletion` endpoint.
- [x] Add `ACCOUNT_MARKED_FOR_DELETION` and `ALREADY_MARKED` error codes.
- [x] Block login for marked users in `/api/login`.
- [x] Block password reset for marked users in `/api/user/request-reset`.
- [x] Trigger `user_marked_for_deletion` SSE event after marking.
- [x] All backend tests pass.
### Frontend
- [x] Add "Delete My Account" button to `UserProfile.vue` below "Change Password".
- [x] Implement warning modal with email confirmation.
- [x] Implement success modal.
- [x] Implement error modal.
- [x] Implement loading state during API call.
- [x] Sign out user after successful account marking.
- [x] Redirect to login page after sign-out.
- [x] Add `ACCOUNT_MARKED_FOR_DELETION` and `ALREADY_MARKED` to `errorCodes.ts`.
- [x] All frontend tests pass.

View File

@@ -0,0 +1,65 @@
# Feature: Replace the text-based "Parent" button with an image icon and modernize the dropdown menu
## Visual Reference:
- **Sample Design:** #mockup.png
- **Design:**
1. Dropdown header colors need to match color theme inside #colors.css
2. The icon button shall be circular and use all the space of it's container. It should be centered in it's container.
3. The three dropdown items should be "Profile", "Child Mode", and "Sign out"
4. Currently, the dropdown shows "Log out" for "Child Mode", that should be changed to "Child Mode"
## Context:
- **Goal:** I want a user image icon to display in place of the current "Parent" button
- **User Story:** As a [user], I want to see the image assigned in my profile as an icon button at the top right of the screen. When I click the button I want to see a dropdown appear if I'm in 'parent mode.' I to have the options to see/edit my profile, go back to child mode, or sign out. In child mode, I want the button to trigger the parent pin modal if clicked.
## Technical Requirements
- **File Affected:** LoginButton.vue, ParentLayout.vue, ChildLayout.vue, AuthLayout.vue
- **Backend:** When LoginButton loads, it should query the backend for the current user data (/user/profile) The returned data will provide the image_id and first_name of the user.
- **Navigation:**
1. When the avatar button is focused, pressing Enter or Space opens the dropdown.
2. When the dropdown is open:
- Up/Down arrow keys move focus between menu items.
- Enter or Space activates the focused menu item.
- Esc closes the dropdown and returns focus to the avatar button.
3. Tabbing away from the dropdown closes it.
- **ARIA:**
1. The avatar button must have aria-haspopup="menu" and aria-expanded reflecting the dropdown state.
2. The dropdown menu must use role="menu", and each item must use role="menuitem".
3. The currently focused menu item should have aria-selected="true".
- **Focus Ring:** All interactive elements (avatar button and dropdown menu items) must display a visible focus ring when focused via keyboard navigation. The focus ring color should use a theme variable from colors.css and meet accessibility contrast guidelines.
- **Mobile & Layout:**
1. The avatar icon button must always be positioned at the top right of the screen, regardless of device size.
2. The icon must never exceed 44px in width or height.
3. On mobile, ensure the button is at least 44x44px for touch accessibility.
- **Avatar Fallback:** If user.first_name does not exist, display a ? as the fallback initial.
- **Dropdown Placement and Animation:**
1. The dropdown menu must always appear directly below the avatar icon, right-aligned to the screen edge.
2. Use a slide down/up animation for showing/hiding the dropdown.
- **State Requirements:**
- Collapsed: Button shows the user.image_id or a fallback icon with the initial of the user.first_name
- Expanded: Shows the dropdown with the three menu options shown in the #mockup.png. -**Menu Item Icons:**: For now, use a stub element or placeholder for each menu item icon, to be replaced with real icons later.
- **Logic:**
1. Clicking an item in the dropdown should already be implemented. Do not change this.
2. When clicking a menu item or clicking outside the menu, collapse the menu.
3. When in 'child mode' (parent not authenticated), show the parent PIN modal or create PIN view (/parent/pin-setup) if user.pin doesn't exist or is empty. (this is already implemented)
## UI Acceptance Criteria (The "Definition of Done")
- [x] UI: Swap the "Parent" button with the user's avatar image.
- [x] UI: Refactor #LoginButton.vue to use new CSS generated from #mockup.png
- [x] Logic: Make sure the dropdown does not show when in child mode.
- [x] Logic: Make sure the parent PIN modal shows when the button is pressed in child mode.
- [x] Logic: Make sure the parent PIN creation view shows when the button is pressed in child mode if no user.pin doesn't exist or is empty.
- [x] Frontend Tests: Add vitest for this feature in the frontend to make sure the logic for button clicking in parent mode and child mode act correctly.
1. [x] Avatar button renders image, initial, or ? as fallback
2. [x] Dropdown opens/closes via click, Enter, Space, Esc, and outside click.
3. [x] Dropdown is positioned and animated correctly.
4. [x] Keyboard navigation (Up/Down, Enter, Space, Esc) works as specified.
5. [x] ARIA attributes and roles are set correctly.
6. [x] Focus ring is visible and uses theme color.
7. [x] Avatar button meets size and position requirements on all devices.
8. [x] Menu logic for parent/child mode is correct.
9. [x] Stub icons are rendered for menu items.

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB