feat: add account deletion feature (#85)

* feat: add account deletion feature

- Add DELETE /auth/me endpoint to delete user account and all data
- Add deleteAccount() method to AuthContext
- Add Delete Account button with confirmation modal in UserProfile
- Add danger zone styling (.btn-danger, .profile-danger-zone)
- Add i18n translations for EN and DE
- Add backend tests for account deletion endpoint
- Add timeout settings to frontend vitest.config.ts
- Reduce CI timeout for frontend tests (10min -> 5min)

* fix: improve delete account section layout

- Make profile modal scrollable with max-height
- Add proper horizontal margin to danger zone
- Align delete section with form content

* fix: use ConfirmModal component for delete account dialog

- Replace inline modal with existing ConfirmModal component
- Ensures consistent button styling across all modals
- Add UI consistency rule to AGENTS.md and copilot-instructions.md

* fix: consistent styling for delete account section

- Remove warning text (users know what delete means)
- Remove border-bottom from danger zone title (section has border-top)
- Update copilot-instructions and AGENTS.md with stricter UI consistency rules
- Remove unused deleteAccountHint i18n keys

* chore: remove pre-push test hook (CI handles tests)

Tests were running twice - in pre-push hook and GitHub CI.
Removing local pre-push tests since CI provides authoritative test results.
Use 'npm test' manually before pushing if you want local feedback.
This commit is contained in:
Daniel Volz
2026-01-30 21:13:11 +01:00
committed by GitHub
parent 9ed039724e
commit 1dcd333fde
12 changed files with 219 additions and 1368 deletions
+65 -1
View File
@@ -1,5 +1,6 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfirmModal } from "./ConfirmModal";
// =============================================================================
// Types (no roles - all users are equal)
@@ -32,6 +33,7 @@ interface AuthContextType {
updateProfile: (data: { currentPassword?: string; newPassword?: string }) => Promise<void>;
uploadAvatar: (file: File) => Promise<void>;
deleteAvatar: () => Promise<void>;
deleteAccount: () => Promise<void>;
authFetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
@@ -254,6 +256,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
await refreshUser();
}
// Delete account
async function deleteAccount() {
const res = await fetch("/api/auth/me", {
method: "DELETE",
credentials: "include",
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Delete failed" }));
throw new Error(err.error || "Delete failed");
}
setUser(null);
}
// Fetch wrapper that automatically refreshes token on 401
const authFetch = useCallback(
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
@@ -295,6 +312,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
updateProfile,
uploadAvatar,
deleteAvatar,
deleteAccount,
authFetch,
}}
>
@@ -551,7 +569,7 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () =>
// =============================================================================
export function UserProfile({ onClose }: { onClose?: () => void }) {
const { t } = useTranslation();
const { user, updateProfile, uploadAvatar, deleteAvatar } = useAuth();
const { user, updateProfile, uploadAvatar, deleteAvatar, deleteAccount } = useAuth();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
@@ -559,6 +577,8 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
const [success, setSuccess] = useState("");
const [loading, setLoading] = useState(false);
const [avatarLoading, setAvatarLoading] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Close on Escape key
@@ -635,6 +655,18 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
}
}
async function handleDeleteAccount() {
setDeleteLoading(true);
setError("");
try {
await deleteAccount();
// User will be logged out automatically
} catch (err) {
setError(err instanceof Error ? err.message : "Delete failed");
setDeleteLoading(false);
}
}
if (!user) return null;
const hasChanges = currentPassword || newPassword || confirmPassword;
@@ -735,6 +767,38 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
</button>
</div>
</form>
{/* Delete Account Section */}
<div className="profile-section profile-danger-zone">
<h3 className="profile-section-title">{t("auth.deleteAccount", "Delete Account")}</h3>
<button type="button" className="btn btn-danger" onClick={() => setShowDeleteConfirm(true)}>
{t("auth.deleteAccount", "Delete Account")}
</button>
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<ConfirmModal
title={t("auth.deleteAccountConfirmTitle", "Delete Account?")}
message={
<>
<p>
{t(
"auth.deleteAccountConfirmText",
"This will permanently delete your account and all your data (medications, settings, history). This action cannot be undone."
)}
</p>
{error && <div className="auth-error">{error}</div>}
</>
}
confirmLabel={t("auth.deleteAccountButton", "Yes, delete my account")}
cancelLabel={t("common.cancel", "Cancel")}
onConfirm={handleDeleteAccount}
onCancel={() => setShowDeleteConfirm(false)}
isLoading={deleteLoading}
confirmVariant="danger"
/>
)}
</div>
);
}
+5 -2
View File
@@ -274,8 +274,11 @@ export function SharedSchedule() {
for (let d = new Date(startDate); d <= end; d.setDate(d.getDate() + blister.every)) {
const t = d.getTime();
const isPast = d < todayStart;
// Generate dose ID matching Dashboard format: ${med.id}-${blisterIdx}-${whenMs}
const doseId = `${med.id}-${blisterIdx}-${t}`;
// Use date-only timestamp for stable ID (immune to time changes)
// This ensures changing intake times doesn't invalidate past dose tracking
// Must match buildSchedulePreview in schedule.ts exactly
const dateOnlyMs = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const doseId = `${med.id}-${blisterIdx}-${dateOnlyMs}`;
doses.push({
id: doseId,
when: t,
+5 -1
View File
@@ -309,7 +309,11 @@
"avatarUpdated": "Avatar aktualisiert",
"avatarRemoved": "Avatar entfernt",
"loginWithSSO": "Mit {{provider}} anmelden",
"or": "oder"
"or": "oder",
"deleteAccount": "Konto löschen",
"deleteAccountConfirmTitle": "Konto löschen?",
"deleteAccountConfirmText": "Dadurch werden dein Konto und alle deine Daten (Medikamente, Einstellungen, Verlauf) dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"deleteAccountButton": "Ja, mein Konto löschen"
},
"common": {
"loading": "Wird geladen...",
+5 -1
View File
@@ -311,7 +311,11 @@
"avatarUpdated": "Avatar updated",
"avatarRemoved": "Avatar removed",
"loginWithSSO": "Login with {{provider}}",
"or": "or"
"or": "or",
"deleteAccount": "Delete Account",
"deleteAccountConfirmTitle": "Delete Account?",
"deleteAccountConfirmText": "This will permanently delete your account and all your data (medications, settings, history). This action cannot be undone.",
"deleteAccountButton": "Yes, delete my account"
},
"common": {
"loading": "Loading...",
+35 -1
View File
@@ -4182,7 +4182,8 @@ h3 .reminder-icon.info-tooltip {
.profile-modal {
max-width: 420px;
padding: 0;
overflow: hidden;
overflow-y: auto;
max-height: 90vh;
}
.profile-container {
@@ -4366,6 +4367,39 @@ h3 .reminder-icon.info-tooltip {
cursor: not-allowed;
}
/* Profile danger zone */
.profile-danger-zone {
margin: 0 1.5rem 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-primary);
}
.profile-danger-zone .profile-section-title {
border-bottom: none;
padding-bottom: 0;
margin-bottom: 0.75rem;
}
.btn-danger {
background: #dc2626;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 6px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease;
}
.btn-danger:hover:not(:disabled) {
background: #b91c1c;
}
.btn-danger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* =============================================================================
About Modal
============================================================================= */
File diff suppressed because it is too large Load Diff