mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Revert the nine-group consolidation and the card relocations back to the flat sidebar: sessions, trusted devices, and login activity return to Privacy, and account deletion returns inline under Advanced. Drop the /settings/layout redirect and the now-unused group and sign-in cross-link i18n keys across all six locales. Keep the collapse-by-default sessions and activity cards, the medical-history cross-link from "About me", and the chronic-conditions consolidation.
480 lines
17 KiB
TypeScript
480 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { BookOpenCheck, Loader2, Trash2 } from "lucide-react";
|
|
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { ResearchModeAcknowledgmentDialog } from "@/components/medications/research-mode-acknowledgment-dialog";
|
|
import { formatDateTime } from "@/lib/format";
|
|
import { SettingsCard } from "@/components/settings/settings-card";
|
|
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { queryKeys } from "@/lib/query-keys";
|
|
import {
|
|
type ResearchModeStatus,
|
|
researchModeGateState,
|
|
} from "@/lib/medications/research-mode-types";
|
|
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
|
|
|
/**
|
|
* Settings → Advanced.
|
|
*
|
|
* v1.4.16 phase B7: every export path (CSV, JSON, doctor-report PDF)
|
|
* moved out into the dedicated `<ExportSection>` under `/settings/export`.
|
|
* What stays here is the irreversible danger-zone — the "wipe all my
|
|
* data" surface that should never live next to a single-click export
|
|
* button.
|
|
*
|
|
* v1.4.25 W19c-Frontend adds the Research Mode toggle on this page.
|
|
* Research Mode and the data-reset are both opt-in / version-gated
|
|
* controls; they share a semantic shelf even though they touch
|
|
* different parts of the user record. Toggle ON opens the
|
|
* acknowledgment dialog (the user reads + confirms before the server
|
|
* stamps the version). Toggle OFF fires DELETE directly. A separate
|
|
* amber banner above the toggle catches the version-mismatch case
|
|
* (server bumped the disclaimer while the user was still
|
|
* acknowledged) and surfaces a Re-acknowledge CTA that re-opens the
|
|
* dialog.
|
|
*/
|
|
export function AdvancedSection() {
|
|
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
|
|
// `<SettingsSectionFrame>` in the route; this body is the advanced cards.
|
|
// v1.18.6.1 — the guided tour replay card was removed: the tour now exists
|
|
// only as the first-time auto-start after onboarding. This page is the
|
|
// research-mode + data/account destructive controls.
|
|
return (
|
|
<div className="space-y-6">
|
|
<ResearchModeCard />
|
|
<DataResetCard />
|
|
<AccountDeleteCard />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* v1.4.25 W21 Fix-N (simp-H3) — pull the nested ternary that decided
|
|
* the toggle's caption out of the JSX. The four-way ladder (loading /
|
|
* enabled-open / enabled-stale / disabled) is dense to read inline,
|
|
* and the surface stays open to a fifth state ("never queried") when
|
|
* we wire the offline shell in v1.4.26.
|
|
*/
|
|
function researchModeStatusLabel(
|
|
status: ResearchModeStatus | null | undefined,
|
|
isLoading: boolean,
|
|
t: (key: string, params?: Record<string, string | number>) => string,
|
|
): string {
|
|
if (isLoading) return t("common.loading");
|
|
const gate = researchModeGateState(status);
|
|
if (gate === "off") return t("settings.researchMode.disabledStatus");
|
|
if (gate === "stale") return t("settings.researchMode.enabledStaleStatus");
|
|
return t("settings.researchMode.acknowledgedOn", {
|
|
date: status?.acknowledgedAt ? formatDateTime(status.acknowledgedAt) : "—",
|
|
});
|
|
}
|
|
|
|
function ResearchModeCard() {
|
|
const { t } = useTranslations();
|
|
const queryClient = useQueryClient();
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [toggleBusy, setToggleBusy] = useState(false);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
|
|
const { data: status, isLoading } = useQuery<ResearchModeStatus | null>({
|
|
queryKey: queryKeys.researchMode(),
|
|
queryFn: async () => {
|
|
const res = await apiFetchRaw("/api/auth/me/research-mode");
|
|
if (!res.ok) return null;
|
|
const json = await res.json();
|
|
return json.data as ResearchModeStatus;
|
|
},
|
|
staleTime: 60 * 1000,
|
|
});
|
|
|
|
const gateState = researchModeGateState(status);
|
|
const showRePrompt = gateState === "stale";
|
|
// The Switch's `checked` mirrors the server flag exactly — even when
|
|
// the version is stale we keep the toggle "on" so the user sees that
|
|
// their previous choice is preserved; the banner above the toggle
|
|
// explains why the chart isn't painting.
|
|
const switchChecked = !!status?.enabled;
|
|
|
|
async function handleToggle(next: boolean) {
|
|
if (toggleBusy) return;
|
|
setErrorMessage(null);
|
|
if (next) {
|
|
// Toggle ON → open the acknowledgment dialog. The dialog owns
|
|
// the POST; on success it invalidates the `research-mode` query
|
|
// and the toggle reflects the new state.
|
|
setDialogOpen(true);
|
|
return;
|
|
}
|
|
// Toggle OFF → fire DELETE directly.
|
|
setToggleBusy(true);
|
|
try {
|
|
const res = await apiFetchRaw("/api/auth/me/research-mode", {
|
|
method: "DELETE",
|
|
});
|
|
if (!res.ok) {
|
|
setErrorMessage(t("settings.researchMode.disableError"));
|
|
return;
|
|
}
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.researchMode() });
|
|
} catch {
|
|
setErrorMessage(t("settings.researchMode.disableError"));
|
|
} finally {
|
|
setToggleBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<SettingsCard data-slot="settings-research-mode-card">
|
|
<SettingsCardHeader
|
|
icon={BookOpenCheck}
|
|
title={t("settings.researchMode.sectionTitle")}
|
|
description={t("settings.researchMode.subtitle")}
|
|
/>
|
|
|
|
{showRePrompt && (
|
|
<div
|
|
role="alert"
|
|
data-slot="settings-research-mode-reprompt"
|
|
className="border-warning/40 bg-warning/10 mt-4 rounded-md border-l-4 px-3 py-2 text-sm"
|
|
>
|
|
<p className="text-foreground font-medium">
|
|
{t("settings.researchMode.rePromptTitle")}
|
|
</p>
|
|
<p className="text-muted-foreground mt-1 text-xs">
|
|
{t("settings.researchMode.rePromptBody")}
|
|
</p>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="mt-2 min-h-11 sm:min-h-9"
|
|
onClick={() => setDialogOpen(true)}
|
|
data-slot="settings-research-mode-reprompt-cta"
|
|
>
|
|
{t("settings.researchMode.rePromptCta")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-4 flex items-start justify-between gap-3">
|
|
<div className="space-y-0.5">
|
|
<p className="text-sm font-medium">
|
|
{t("settings.researchMode.toggleLabel")}
|
|
</p>
|
|
<p
|
|
className="text-muted-foreground text-xs"
|
|
data-slot="settings-research-mode-status"
|
|
>
|
|
{researchModeStatusLabel(status, isLoading, t)}
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={switchChecked}
|
|
disabled={isLoading || toggleBusy}
|
|
onCheckedChange={(next) => {
|
|
void handleToggle(next);
|
|
}}
|
|
aria-label={t("settings.researchMode.toggleLabel")}
|
|
data-slot="settings-research-mode-toggle"
|
|
/>
|
|
</div>
|
|
|
|
{errorMessage && (
|
|
<p
|
|
role="alert"
|
|
className="text-destructive mt-3 text-sm"
|
|
data-slot="settings-research-mode-error"
|
|
>
|
|
{errorMessage}
|
|
</p>
|
|
)}
|
|
|
|
<ResearchModeAcknowledgmentDialog
|
|
open={dialogOpen}
|
|
onOpenChange={setDialogOpen}
|
|
currentDisclaimerVersion={status?.currentDisclaimerVersion ?? null}
|
|
/>
|
|
</SettingsCard>
|
|
);
|
|
}
|
|
|
|
function DataResetCard() {
|
|
const { t } = useTranslations();
|
|
const queryClient = useQueryClient();
|
|
const [deleting, setDeleting] = useState(false);
|
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
const [msg, setMsg] = useState<string | null>(null);
|
|
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
|
|
|
async function handleDeleteAllData() {
|
|
if (deleting) return;
|
|
setDeleting(true);
|
|
setMsg(null);
|
|
setMsgType(null);
|
|
try {
|
|
const res = await apiFetchRaw("/api/settings/data", {
|
|
method: "DELETE",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ confirm: "DELETE" }),
|
|
});
|
|
const json = await res.json();
|
|
if (!res.ok) {
|
|
setMsg(json.error || t("settings.dangerZoneDeleteFailed"));
|
|
setMsgType("error");
|
|
return;
|
|
}
|
|
|
|
await queryClient.invalidateQueries();
|
|
setMsg(t("settings.dangerZoneSuccess"));
|
|
setMsgType("success");
|
|
setConfirmOpen(false);
|
|
} catch {
|
|
setMsg(t("settings.dangerZoneDeleteFailed"));
|
|
setMsgType("error");
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<SettingsCard data-slot="settings-data-reset-card">
|
|
{/* v1.4.43 QoL (L5) — dropped the `AlertTriangle` icon and
|
|
neutralised the title colour so the danger-zone shaping is
|
|
GitHub-style (red CTA only) rather than red-on-red-on-red.
|
|
The protective gate (confirmation dialog) is unchanged; this
|
|
is purely a visual-tone fix per the v1.4.43 audit. */}
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
|
<div className="space-y-1">
|
|
<h2 className="text-foreground text-lg font-semibold">
|
|
{t("settings.dangerZone")}
|
|
</h2>
|
|
<p className="text-muted-foreground text-xs">
|
|
{t("settings.dangerZoneDescription")}
|
|
</p>
|
|
</div>
|
|
<AlertDialog
|
|
open={confirmOpen}
|
|
onOpenChange={(open) => {
|
|
// Hold the dialog open while the destructive mutation is in
|
|
// flight so the in-dialog pending state stays visible and a
|
|
// stray backdrop tap can't dismiss it mid-request.
|
|
if (deleting) return;
|
|
setConfirmOpen(open);
|
|
}}
|
|
>
|
|
<AlertDialogTrigger asChild>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
disabled={deleting}
|
|
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
|
|
>
|
|
{deleting ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
|
) : (
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
)}
|
|
{t("settings.dangerZone")}
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
{t("settings.dangerZoneConfirm")}
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t("settings.dangerZoneConfirmDescription")}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={deleting}>
|
|
{t("common.cancel")}
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
variant="destructive"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
void handleDeleteAllData();
|
|
}}
|
|
disabled={deleting}
|
|
aria-busy={deleting || undefined}
|
|
data-slot="settings-data-reset-confirm"
|
|
>
|
|
{deleting && (
|
|
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
|
)}
|
|
{t("settings.finalDelete")}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
|
|
{msg && (
|
|
<p
|
|
role="alert"
|
|
className={`mt-3 text-sm ${msgType === "success" ? "text-success" : "text-destructive"}`}
|
|
>
|
|
{msg}
|
|
</p>
|
|
)}
|
|
</SettingsCard>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* v1.4.43 QoL (M3) — separate destructive action for the full account
|
|
* delete. Pre-fix, the only "danger zone" CTA wiped the user's health
|
|
* data but left the user row, passkeys, audit log, and sessions
|
|
* intact — half of what a user reading "Danger Zone" expects per
|
|
* GDPR Article 17. The route `DELETE /api/settings/account` already
|
|
* cascades User + passkeys + audit log + sessions (and is rate-limited
|
|
* by the API handler); this card is its UI front door.
|
|
*
|
|
* Visually the card mirrors the data-reset shaping (neutral title,
|
|
* red CTA only) but the dialog copy is explicit: this deletes the
|
|
* account, not just the data, and the user will be signed out
|
|
* immediately.
|
|
*/
|
|
function AccountDeleteCard() {
|
|
const { t } = useTranslations();
|
|
const [deleting, setDeleting] = useState(false);
|
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
const [msg, setMsg] = useState<string | null>(null);
|
|
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
|
|
|
async function handleDeleteAccount() {
|
|
if (deleting) return;
|
|
setDeleting(true);
|
|
setMsg(null);
|
|
setMsgType(null);
|
|
try {
|
|
const res = await apiFetchRaw("/api/settings/account", {
|
|
method: "DELETE",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }),
|
|
});
|
|
if (!res.ok) {
|
|
try {
|
|
const json = await res.json();
|
|
setMsg(json.error || t("settings.deleteAccountFailed"));
|
|
} catch {
|
|
setMsg(t("settings.deleteAccountFailed"));
|
|
}
|
|
setMsgType("error");
|
|
setDeleting(false);
|
|
return;
|
|
}
|
|
setMsg(t("settings.deleteAccountSuccess"));
|
|
setMsgType("success");
|
|
// The route destroyed every session before deleting the row;
|
|
// give the toast a beat to paint, then bounce to the login page.
|
|
// Leave `deleting` set so the confirm button keeps its pending
|
|
// state through the redirect — the row is gone, nothing to undo.
|
|
setTimeout(() => {
|
|
window.location.href = "/auth/login";
|
|
}, 1_500);
|
|
} catch {
|
|
setMsg(t("settings.deleteAccountFailed"));
|
|
setMsgType("error");
|
|
setDeleting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<SettingsCard data-slot="settings-account-delete-card">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
|
<div className="space-y-1">
|
|
<h2 className="text-foreground text-lg font-semibold">
|
|
{t("settings.deleteAccountCardTitle")}
|
|
</h2>
|
|
<p className="text-muted-foreground text-xs">
|
|
{t("settings.deleteAccountCardDescription")}
|
|
</p>
|
|
</div>
|
|
<AlertDialog
|
|
open={confirmOpen}
|
|
onOpenChange={(open) => {
|
|
// Hold the dialog open while the irreversible delete runs so
|
|
// the pending state stays on screen and a stray dismissal
|
|
// can't fire a second request.
|
|
if (deleting) return;
|
|
setConfirmOpen(open);
|
|
}}
|
|
>
|
|
<AlertDialogTrigger asChild>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
disabled={deleting}
|
|
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
|
|
data-slot="settings-account-delete-trigger"
|
|
>
|
|
{deleting ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
|
) : (
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
)}
|
|
{t("settings.deleteAccountCta")}
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
{t("settings.deleteAccountConfirmTitle")}
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t("settings.deleteAccountConfirmDescription")}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={deleting}>
|
|
{t("common.cancel")}
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
variant="destructive"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
void handleDeleteAccount();
|
|
}}
|
|
disabled={deleting}
|
|
aria-busy={deleting || undefined}
|
|
data-slot="settings-account-delete-confirm"
|
|
>
|
|
{deleting && (
|
|
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
|
)}
|
|
{t("settings.deleteAccountFinal")}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
|
|
{msg && (
|
|
<p
|
|
role="alert"
|
|
className={`mt-3 text-sm ${msgType === "success" ? "text-success" : "text-destructive"}`}
|
|
>
|
|
{msg}
|
|
</p>
|
|
)}
|
|
</SettingsCard>
|
|
);
|
|
}
|