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

@@ -30,6 +30,13 @@
>
Change Password
</button>
<button
type="button"
class="btn-link align-start btn-link-space"
@click="openDeleteWarning"
>
Delete My Account
</button>
</div>
</template>
</EntityEditForm>
@@ -45,6 +52,55 @@
<button class="btn btn-primary" @click="handlePasswordModalClose">OK</button>
</div>
</ModalDialog>
<!-- Delete Account Warning Modal -->
<ModalDialog v-if="showDeleteWarning" title="Delete Your Account?" @close="closeDeleteWarning">
<div class="modal-message">
This will permanently delete your account and all associated data. This action cannot be
undone.
</div>
<div class="form-group" style="margin-top: 1rem">
<label for="confirmEmail">Type your email address to confirm:</label>
<input
id="confirmEmail"
v-model="confirmEmail"
type="email"
class="email-confirm-input"
placeholder="Enter your email"
:disabled="deletingAccount"
/>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" @click="closeDeleteWarning" :disabled="deletingAccount">
Cancel
</button>
<button
class="btn btn-danger"
@click="confirmDeleteAccount"
:disabled="!isEmailValid(confirmEmail) || deletingAccount"
>
{{ deletingAccount ? 'Deleting...' : 'Delete' }}
</button>
</div>
</ModalDialog>
<!-- Delete Account Success Modal -->
<ModalDialog v-if="showDeleteSuccess" title="Account Deleted">
<div class="modal-message">
Your account has been marked for deletion. You will now be signed out.
</div>
<div class="modal-actions">
<button class="btn btn-primary" @click="handleDeleteSuccess">OK</button>
</div>
</ModalDialog>
<!-- Delete Account Error Modal -->
<ModalDialog v-if="showDeleteError" title="Error" @close="closeDeleteError">
<div class="modal-message">{{ deleteErrorMessage }}</div>
<div class="modal-actions">
<button class="btn btn-primary" @click="closeDeleteError">Close</button>
</div>
</ModalDialog>
</div>
</template>
@@ -53,6 +109,9 @@ import { ref, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import EntityEditForm from '../shared/EntityEditForm.vue'
import ModalDialog from '../shared/ModalDialog.vue'
import { parseErrorResponse, isEmailValid } from '@/common/api'
import { ALREADY_MARKED } from '@/common/errorCodes'
import { logoutUser } from '@/stores/auth'
import '@/assets/styles.css'
const router = useRouter()
@@ -66,6 +125,14 @@ const modalTitle = ref('')
const modalSubtitle = ref('')
const modalMessage = ref('')
// Delete account modal state
const showDeleteWarning = ref(false)
const confirmEmail = ref('')
const deletingAccount = ref(false)
const showDeleteSuccess = ref(false)
const showDeleteError = ref(false)
const deleteErrorMessage = ref('')
const initialData = ref({
image_id: null,
first_name: '',
@@ -216,6 +283,63 @@ async function resetPassword() {
function goToChangeParentPin() {
router.push({ name: 'ParentPinSetup' })
}
function openDeleteWarning() {
confirmEmail.value = ''
showDeleteWarning.value = true
}
function closeDeleteWarning() {
showDeleteWarning.value = false
confirmEmail.value = ''
}
async function confirmDeleteAccount() {
console.log('Confirming delete account with email:', confirmEmail.value)
if (!isEmailValid(confirmEmail.value)) return
deletingAccount.value = true
try {
const res = await fetch('/api/user/mark-for-deletion', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: confirmEmail.value }),
})
if (!res.ok) {
const { msg, code } = await parseErrorResponse(res)
let errorMessage = msg
if (code === ALREADY_MARKED) {
errorMessage = 'This account is already marked for deletion.'
}
deleteErrorMessage.value = errorMessage
showDeleteWarning.value = false
showDeleteError.value = true
return
}
// Success
showDeleteWarning.value = false
showDeleteSuccess.value = true
} catch {
deleteErrorMessage.value = 'Network error. Please try again.'
showDeleteWarning.value = false
showDeleteError.value = true
} finally {
deletingAccount.value = false
}
}
function handleDeleteSuccess() {
showDeleteSuccess.value = false
logoutUser()
router.push('/auth/login')
}
function closeDeleteError() {
showDeleteError.value = false
deleteErrorMessage.value = ''
}
</script>
<style scoped>
@@ -257,4 +381,42 @@ function goToChangeParentPin() {
color: var(--form-label, #888);
box-sizing: border-box;
}
.btn-danger-link {
background: none;
border: none;
color: var(--error, #e53e3e);
font-size: 0.95rem;
cursor: pointer;
padding: 0;
text-decoration: underline;
margin-top: 0.25rem;
align-self: flex-start;
}
.btn-danger-link:hover {
color: var(--error-hover, #c53030);
}
.btn-danger-link:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.email-confirm-input {
width: 100%;
padding: 0.6rem;
border-radius: 7px;
border: 1px solid var(--form-input-border, #e6e6e6);
font-size: 1rem;
background: var(--form-input-bg, #ffffff);
color: var(--text, #1a1a1a);
box-sizing: border-box;
margin-top: 0.5rem;
}
.email-confirm-input:focus {
outline: none;
border-color: var(--btn-primary, #4a90e2);
}
</style>