mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
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:
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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user