chore(release): v1.4.45 — analytics 9 s perf fix + audit-marathon closure + v1.4.44 hotfix merged

Audit-marathon close: 14 waves + W10 QA + reconcile. Perf fix (analytics 9 s -> 2-3 s), Zod multi-issue rollout (41 routes), Withings parked-state automation, mobile-UI WCAG 2.5.5 close, QoL i18n + offline banner + account delete, security PII drop + replay-injection scan. Migration 0075 required. Tests 4815 -> 5076.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-21 23:13:21 +02:00
1 parent 324d4bb411
commit 6c7fc7dc84
167 files changed
+11233 -371

No files matched your search

@@ -0,0 +1,107 @@
/**
* v1.4.43 QoL (M3 + L5) — `<AdvancedSection>` danger-zone shaping.
*
* M3: a separate "Delete account entirely" destructive card
* (`AccountDeleteCard`) sits beside the existing `DataResetCard`
* so a user reading the GDPR Article 17 surface has the full
* erasure CTA right next to the half-erasure one.
*
* L5: the data-reset card no longer paints a red `AlertTriangle`
* icon and the title sits in the neutral foreground colour
* (mirrors GitHub's danger-zone shaping). The CTA button stays
* red; the protective confirmation dialog stays unchanged.
*
* SSR-only — we render the component statically and inspect the
* markup. The mutation handlers don't fire in SSR, so this test only
* pins the visible structure + copy.
*/
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
// `<AdvancedSection>` mounts the Research Mode card which calls
// `useQuery` and `useQueryClient` under TanStack Query. The other
// shape tests use the same stub.
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: undefined, isLoading: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/components/medications/ResearchModeAcknowledgmentDialog", () => ({
ResearchModeAcknowledgmentDialog: () => (
<div data-slot="mock-ack-dialog" />
),
}));
import { AdvancedSection } from "../advanced-section";
function render(locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<AdvancedSection />
</I18nProvider>,
);
}
describe("<AdvancedSection> danger-zone shaping (v1.4.43 M3 + L5)", () => {
it("mounts both the data-reset card and the account-delete card", () => {
const html = render();
expect(html).toContain('data-slot="settings-data-reset-card"');
expect(html).toContain('data-slot="settings-account-delete-card"');
});
it("L5: the data-reset card title is neutral foreground (no destructive red)", () => {
const html = render();
// The reset card's title used to ship with `text-destructive`; the
// v1.4.43 fix pins it on `text-foreground`. We don't care if other
// strings in the section happen to use `text-destructive` (the CTA
// button does), only that the specific card title slot does NOT.
const cardMatch = html.match(
/data-slot="settings-data-reset-card"[\s\S]*?<\/h2>/,
);
expect(cardMatch).not.toBeNull();
expect(cardMatch![0]).toContain("text-foreground");
expect(cardMatch![0]).not.toContain("text-destructive");
});
it("L5: the data-reset card no longer ships an AlertTriangle icon", () => {
const html = render();
// The icon used to render with a lucide-issued `lucide-alert-triangle`
// class. Pin the absence; if a future revision re-imports the icon
// the SVG class will reappear and this test catches it.
const cardMatch = html.match(
/data-slot="settings-data-reset-card"[\s\S]*?<\/h2>/,
);
expect(cardMatch).not.toBeNull();
expect(cardMatch![0]).not.toMatch(/lucide-alert-triangle/);
});
it("M3: the account-delete card uses the destructive Button variant", () => {
const html = render();
expect(html).toContain('data-slot="settings-account-delete-trigger"');
// The trigger button still wears the destructive style — the CTA
// remains red even though the surrounding card chrome is neutral.
const triggerMatch = html.match(
/data-slot="settings-account-delete-trigger"[\s\S]*?<\/button>/,
);
expect(triggerMatch).not.toBeNull();
// The destructive button uses the `bg-destructive` class from the
// shadcn variant.
expect(triggerMatch![0]).toContain("bg-destructive");
});
it("M3: the account-delete card surfaces tight DE copy under the 'de' locale", () => {
const html = render("de");
expect(html).toContain("Konto vollständig löschen");
// The descriptive sentence calls out the cascade scope plainly.
expect(html).toContain("Passkeys");
});
it("M3: the account-delete card surfaces tight EN copy under the 'en' locale", () => {
const html = render("en");
expect(html).toContain("Delete account entirely");
expect(html).toContain("passkeys");
});
});
@@ -94,4 +94,27 @@ describe("IntegrationStatusPill", () => {
// narrow on Pixel 5.
expect(html).toMatch(/just\s+now/i);
});
// v1.4.43 W14 — parked-state copy.
// Distinct from `error` (red reconnect pill) because the user can
// resume the integration without redoing the OAuth dance; distinct
// from `warning` because the persistent streak survived the alert
// ladder AND the 24h grace window — manual intervention required.
it("renders the parked state with manual-reconnect phrasing (EN)", () => {
const html = render(
<IntegrationStatusPill state="parked" lastSyncAt={null} />,
);
expect(html).toContain("Paused");
expect(html).toContain("reconnect manually");
expect(html).toContain('data-state="parked"');
});
it("renders the parked state with manual-reconnect phrasing (DE)", () => {
const html = render(
<IntegrationStatusPill state="parked" lastSyncAt={null} />,
"de",
);
expect(html).toContain("Pausiert");
expect(html).toContain("manuell wieder verbinden");
});
});
@@ -285,6 +285,50 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
expect(html).not.toContain("Last sync:");
});
// v1.4.43 W14 — parked-state surface check.
// The pill flips to `data-state="parked"` and the resume banner
// renders with the reconnect CTA. The legacy `error` reconnect text
// (red) must NOT appear because the user-facing copy is the lighter
// "Paused — reconnect manually" string, not "Error — reconnect".
it("Withings parked state surfaces the parked pill + resume CTA", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
{
integration: "withings",
state: "parked",
lastSuccessAt: "2026-05-08T12:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: "Withings activity error: 293",
consecutiveFailures: 30,
},
{
integration: "moodlog",
state: "connected",
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
consecutiveFailures: 0,
},
],
});
setWithingsStatus({
connected: true,
configured: true,
lastSyncedAt: "2026-05-08T12:00:00.000Z",
});
const html = render();
expect(html).toContain('data-state="parked"');
expect(html).toContain("Paused");
expect(html).toContain("reconnect manually");
expect(html).toContain('data-testid="withings-parked-banner"');
expect(html).toContain('data-testid="withings-resume-button"');
// The lastError is surfaced under the pill so the operator sees
// the contract-mismatch reason without opening Audit Log.
expect(html).toContain("Withings activity error: 293");
});
it("Mood Log card carries the visual divider Withings has (consistency)", () => {
setIntegrationStatus({
threshold: 3,
+1 -1
View File
@@ -211,7 +211,7 @@ export function AboutSection() {
{isLoading || !version ? (
<div className="flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("common.loading")}
</span>
+3 -3
View File
@@ -359,7 +359,7 @@ export function AccountSection() {
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin" />
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
@@ -617,7 +617,7 @@ export function AccountSection() {
className="w-full shrink-0 sm:w-auto"
>
{tourRestarting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Compass className="mr-2 h-4 w-4" />
)}
@@ -708,7 +708,7 @@ export function AccountSection() {
<Button type="submit" variant="outline" disabled={passwordSaving}>
{passwordSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
+133 -8
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, BookOpenCheck, Loader2, Trash2 } from "lucide-react";
import { BookOpenCheck, Loader2, Trash2 } from "lucide-react";
import {
AlertDialog,
@@ -68,6 +68,7 @@ export function AdvancedSection() {
<ResearchModeCard />
<DataResetCard />
<AccountDeleteCard />
</section>
);
}
@@ -266,13 +267,18 @@ function DataResetCard() {
}
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<AlertTriangle className="text-destructive h-5 w-5" />
<h2 className="text-destructive text-lg font-semibold">
{t("settings.dangerZone")}
</h2>
</div>
<div
className="bg-card border-border rounded-xl border p-6"
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. */}
<h2 className="text-foreground text-lg font-semibold">
{t("settings.dangerZone")}
</h2>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.dangerZoneDescription")}
</p>
@@ -322,3 +328,122 @@ function DataResetCard() {
</div>
);
}
/**
* 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 [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
async function handleDeleteAccount() {
setDeleting(true);
setMsg(null);
setMsgType(null);
try {
const res = await fetch("/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");
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.
setTimeout(() => {
window.location.href = "/auth/login";
}, 1_500);
} catch {
setMsg(t("settings.deleteAccountFailed"));
setMsgType("error");
} finally {
setDeleting(false);
}
}
return (
<div
className="bg-card border-border rounded-xl border p-6"
data-slot="settings-account-delete-card"
>
<h2 className="text-foreground text-lg font-semibold">
{t("settings.deleteAccountCardTitle")}
</h2>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.deleteAccountCardDescription")}
</p>
<div className="mt-4">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={deleting}
data-slot="settings-account-delete-trigger"
>
{deleting ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="mr-1 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>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDeleteAccount}
data-slot="settings-account-delete-confirm"
>
{t("settings.deleteAccountFinal")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{msg && (
<p
role="alert"
className={`mt-3 text-sm ${msgType === "success" ? "text-dracula-green" : "text-destructive"}`}
>
{msg}
</p>
)}
</div>
);
}
+3 -3
View File
@@ -639,7 +639,7 @@ function CodexProviderForm({
disabled={disconnecting}
>
{disconnecting ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
<Loader2 className="mr-1 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="mr-1 h-4 w-4" />
)}
@@ -682,7 +682,7 @@ function CodexProviderForm({
<div className="flex items-center gap-3 text-xs">
{devicePolling && (
<span className="text-muted-foreground inline-flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
<Loader2 className="h-3 w-3 animate-spin motion-reduce:animate-none" />
{t("settings.ai.deviceCodeWaiting")}
</span>
)}
@@ -703,7 +703,7 @@ function CodexProviderForm({
className="w-full sm:w-auto"
>
{devicePolling ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
@@ -1,6 +1,11 @@
"use client";
import { CheckCircle2, AlertTriangle, CircleSlash } from "lucide-react";
import {
CheckCircle2,
AlertTriangle,
CircleSlash,
PauseCircle,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useTranslations } from "@/lib/i18n/context";
@@ -26,10 +31,24 @@ import { cn } from "@/lib/utils";
* abbreviated time form into a 12 char string at most.
*/
/**
* Pill states.
*
* - connected → happy path; relative "last sync" suffix is shown.
* - warning → connected but the upstream just returned a
* contract-mismatch error (v1.4.43 W4).
* - error → reauth-required / persistent transient error;
* "Error — reconnect" copy.
* - parked → v1.4.43 W14: >24h of persistent failures put the
* integration to sleep; "Pausiert — manuell wieder
* verbinden" copy + reconnect CTA.
* - disconnected → user clicked Disconnect; "Not connected" copy.
*/
export type IntegrationPillState =
| "connected"
| "warning"
| "error"
| "parked"
| "disconnected";
interface IntegrationStatusPillProps {
@@ -78,15 +97,22 @@ export function IntegrationStatusPill({
}: IntegrationStatusPillProps) {
const { t } = useTranslations();
// chipClass only applies to the `connected` and `warning` branches —
// `error` falls back to `variant="destructive"` and `disconnected` to
// `variant="outline"`. The warning chip uses amber tokens to signal
// "connected, but the upstream just returned a contract-level error
// a reauth won't fix" — distinct from the red reconnect pill.
// chipClass only applies to the `connected`, `warning`, and `parked`
// branches — `error` falls back to `variant="destructive"` and
// `disconnected` to `variant="outline"`. The warning chip uses amber
// tokens to signal "connected, but the upstream just returned a
// contract-level error a reauth won't fix" — distinct from the red
// reconnect pill. The parked chip uses the orange tokens to signal
// "we tried to keep going for 24h but the contract mismatch persists
// — manual intervention required" — visually heavier than warning
// (sustained problem), lighter than error (still recoverable without
// re-auth).
const connectedChipClass =
"border-dracula-green/30 bg-dracula-green/15 text-dracula-green";
const warningChipClass =
"border-dracula-yellow/30 bg-dracula-yellow/15 text-dracula-yellow";
const parkedChipClass =
"border-dracula-orange/30 bg-dracula-orange/15 text-dracula-orange";
let label: string;
let icon: React.ReactNode;
@@ -104,6 +130,10 @@ export function IntegrationStatusPill({
label = t("settings.integrationPill.errorReconnect");
icon = <AlertTriangle aria-hidden="true" className="h-3 w-3" />;
break;
case "parked":
label = t("settings.integrationPill.parkedReconnect");
icon = <PauseCircle aria-hidden="true" className="h-3 w-3" />;
break;
case "disconnected":
label = t("settings.integrationPill.notConnected");
icon = <CircleSlash aria-hidden="true" className="h-3 w-3" />;
@@ -124,7 +154,7 @@ export function IntegrationStatusPill({
data-testid="integration-status-pill"
data-state={state}
variant={
state === "connected" || state === "warning"
state === "connected" || state === "warning" || state === "parked"
? undefined
: state === "error"
? "destructive"
@@ -135,6 +165,7 @@ export function IntegrationStatusPill({
"max-w-full whitespace-nowrap",
state === "connected" && connectedChipClass,
state === "warning" && warningChipClass,
state === "parked" && parkedChipClass,
className,
)}
>
@@ -64,7 +64,8 @@ type IntegrationState =
| "connected"
| "error_transient"
| "error_reauth"
| "disconnected";
| "disconnected"
| "parked";
interface IntegrationStatusViewModel {
integration: IntegrationKey;
@@ -73,6 +74,11 @@ interface IntegrationStatusViewModel {
lastAttemptAt: string | null;
lastError: string | null;
consecutiveFailures: number;
consecutiveFailuresByKind?: {
transient: number;
reauth_required: number;
persistent: number;
} | null;
configured?: boolean;
connected?: boolean;
connectedAt?: string | null;
@@ -114,11 +120,14 @@ function pickStatus(
}
/**
* Collapse the API's four-state machine into the three states the
* pill UI cares about: `error_transient` and `error_reauth` both
* Collapse the API's five-state machine into the four states the
* pill UI cares about. `error_transient` and `error_reauth` both
* surface as the same "Error — reconnect" pill, the actionable
* difference (whether the user must reconnect vs wait for the next
* retry) is conveyed via the inline error text underneath.
* retry) is conveyed via the inline error text underneath. `parked`
* (v1.4.43 W14) is its own pill state — the integration has been
* disabled after 24h of persistent failures and needs an explicit
* "Wieder verbinden" click to resume.
*/
function pillStateFor(
status: IntegrationStatusViewModel | undefined,
@@ -128,8 +137,22 @@ function pillStateFor(
case "connected":
return "connected";
case "error_transient":
// v1.4.43 W4 H3 — a `persistent` failure-kind streak (Withings
// rate-limit 601 / contract-mismatch 293/294) maps to the same
// `error_transient` DB state as a normal retryable failure but
// tells the user a different story: the access token still
// works, the upstream is responding with a non-recoverable
// status. Surfacing it as a "warning" pill (orange) instead of
// the red "Fehler — neu verbinden" stops the user from clicking
// reconnect ten times when reconnect can't fix it.
if ((status.consecutiveFailuresByKind?.persistent ?? 0) > 0) {
return "warning";
}
return "error";
case "error_reauth":
return "error";
case "parked":
return "parked";
case "disconnected":
return "disconnected";
}
@@ -256,6 +279,28 @@ function WithingsCard({
},
});
// v1.4.43 W14 — clear a parked integration via the resume endpoint.
// The CTA is rendered inside the parked banner below; success
// invalidates both the per-card status (so the pill flips back to
// connected immediately) and the cross-integration envelope (so any
// other view picks up the change on its next focus).
const resume = useMutation({
mutationFn: async () => {
const res = await fetch("/api/integrations/withings/resume", {
method: "POST",
});
if (!res.ok) throw new Error("Failed");
return (await res.json()).data as {
resumed: boolean;
wasParked: boolean;
};
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() });
},
});
async function handleSync(fullSync = false) {
setSyncing(true);
setSyncMsg(null);
@@ -339,8 +384,13 @@ function WithingsCard({
: "disconnected";
const pillLastSyncAt =
status?.lastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
// v1.4.43 W14 — `parked` and `error` both want the underlying error
// message surfaced under the pill (the pill says "what" — the
// message says "why"). Other states leave the inline line off.
const errorMessage =
pillState === "error" && viewModel?.lastError ? viewModel.lastError : null;
(pillState === "error" || pillState === "parked") && viewModel?.lastError
? viewModel.lastError
: null;
return (
<div className="bg-card border-border rounded-xl border p-6">
@@ -388,6 +438,59 @@ function WithingsCard({
</span>
</a>
)}
{/* v1.4.43 W14 — parked-integration resume CTA. Surfaces only
when the row state is `parked` (>24h of persistent
failures). The button POSTs to /api/integrations/withings/
resume which calls `resumeIntegrationFromPark`; on success
the per-card status invalidates and the pill flips back to
connected without a page refresh. The button is the
primary action the user can take from this card — wider
tap target than the inline action row so it's reachable
on a Pixel 5 viewport. */}
{pillState === "parked" && (
<div
data-testid="withings-parked-banner"
className="border-dracula-orange/30 bg-dracula-orange/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="text-dracula-orange min-w-0 break-words text-xs">
{t("settings.integrationPill.parkedReconnect")}
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => resume.mutate()}
disabled={resume.isPending}
data-testid="withings-resume-button"
className="min-h-[44px] sm:min-h-0"
>
{resume.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Link2 className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.integrationPill.resumeCta")}
</Button>
</div>
)}
{resume.isError && (
<p
role="alert"
className="text-destructive text-xs"
data-testid="withings-resume-error"
>
{t("settings.integrationPill.resumeError")}
</p>
)}
{resume.isSuccess && resume.data?.wasParked && (
<p
role="status"
className="text-dracula-green text-xs"
data-testid="withings-resume-success"
>
{t("settings.integrationPill.resumeSuccess")}
</p>
)}
<div className="space-y-3">
<h3 className="text-sm font-semibold">
{t("settings.withingsCredentials")}
+1 -1
View File
@@ -592,7 +592,7 @@ export function SourcesSection({ mode = "standalone" }: SourcesSectionProps = {}
disabled={saveMutation.isPending}
>
{saveMutation.isPending && (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>