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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user