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
+1
View File
@@ -500,6 +500,7 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp
- **API responses**: Return objects directly, Fastify serializes to JSON
- **Environment**: Copy `.env.example``.env`, secrets must be 10+ chars
- **i18n**: All UI text via `t('key')` function, translations in `frontend/src/i18n/*.json`
- **UI Consistency**: Always use existing components for modals, buttons, and forms. For confirmation dialogs, use `ConfirmModal` component. Never create inline modals with custom button styling - all UI elements must match the existing design system. When adding new sections to existing components, ensure font sizes, spacing, margins, and button styles match exactly with other sections. Check existing CSS classes before creating new ones.
## Database Schema Changes (IMPORTANT: Backward Compatibility!)
+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
- name: Run frontend tests and capture count
id: frontend-tests
working-directory: frontend
timeout-minutes: 10
timeout-minutes: 5
env:
CI: true
run: |
-31
View File
@@ -1,31 +0,0 @@
#!/bin/sh
# Get the directory where the script is located
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
echo "Running backend tests before push..."
cd "$ROOT_DIR/backend" && CI=true npm test
if [ $? -ne 0 ]; then
echo "❌ Backend tests failed. Push aborted."
echo "Use 'git push --no-verify' to skip tests if needed."
exit 1
fi
echo "✅ Backend tests passed!"
echo ""
echo "Running frontend tests before push..."
cd "$ROOT_DIR/frontend" && CI=true npm test
if [ $? -ne 0 ]; then
echo "❌ Frontend tests failed. Push aborted."
echo "Use 'git push --no-verify' to skip tests if needed."
exit 1
fi
echo "✅ Frontend tests passed!"
echo ""
echo "✅ All tests passed! Pushing..."
+40
View File
@@ -534,4 +534,44 @@ export async function authRoutes(app: FastifyInstance) {
return { ok: true };
}
);
// ---------------------------------------------------------------------------
// DELETE /auth/me - Delete user account and all data
// ---------------------------------------------------------------------------
app.delete(
"/auth/me",
{
preHandler: requireAuth,
config: { rateLimit: sensitiveRateLimitConfig },
},
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "Not authenticated" });
}
// Delete avatar file if exists
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
if (user?.avatarUrl) {
const fs = await import("node:fs/promises");
const path = await import("node:path");
try {
await fs.unlink(path.join(process.cwd(), "data", "images", user.avatarUrl));
} catch {
// Ignore if file doesn't exist
}
}
// Delete user - cascade delete handles all related data
await db.delete(users).where(eq(users.id, authUser.id));
app.log.info(`User deleted account: ${authUser.username} (ID: ${authUser.id})`);
// Clear auth cookies
return reply
.clearCookie("access_token", app.config.cookieOptions)
.clearCookie("refresh_token", app.config.refreshCookieOptions)
.send({ ok: true, message: "Account deleted" });
}
);
}
+58
View File
@@ -682,4 +682,62 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
expect(response.statusCode).toBe(401);
});
});
describe("DELETE /auth/me - Delete Account", () => {
it("should delete user account and all data", async () => {
// Register and login
await app.inject({
method: "POST",
url: "/auth/register",
payload: {
username: "deleteuser",
password: "TestPassword123",
},
});
const login = await app.inject({
method: "POST",
url: "/auth/login",
payload: {
username: "deleteuser",
password: "TestPassword123",
},
});
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
// Delete account
const response = await app.inject({
method: "DELETE",
url: "/auth/me",
cookies: {
access_token: accessToken?.value ?? "",
},
});
expect(response.statusCode).toBe(200);
expect(response.json().ok).toBe(true);
// Verify can't login anymore
const loginAgain = await app.inject({
method: "POST",
url: "/auth/login",
payload: {
username: "deleteuser",
password: "TestPassword123",
},
});
expect(loginAgain.statusCode).toBe(401);
});
it("should reject delete without auth", async () => {
const response = await app.inject({
method: "DELETE",
url: "/auth/me",
});
expect(response.statusCode).toBe(401);
});
});
});
+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
+4
View File
@@ -8,6 +8,10 @@ export default defineConfig({
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
// Timeout settings to prevent CI hanging
testTimeout: 10000,
hookTimeout: 10000,
teardownTimeout: 5000,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],