mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat: show managed integration status
- Render a record-keyed status view for managed profiles\n- Refuse shared Settings links before actor controls mount\n- Localize intentional unavailable states
This commit is contained in:
1 parent
54cd3e3959
commit
03ee01ba56
17 files changed
+343
-32
No files matched your search
@@ -4433,6 +4433,11 @@
|
||||
"system": "System"
|
||||
}
|
||||
},
|
||||
"sharedRecord": {
|
||||
"unavailableTitle": "In diesem geteilten Datensatz nicht verfügbar",
|
||||
"unavailableDescription": "Dieser Einstellungsbereich ist beim Anzeigen des Datensatzes einer anderen Person bewusst nicht verfügbar.",
|
||||
"integrationStatusDescription": "Verbindungsstatus für diesen verwalteten Datensatz. Verbindungssteuerungen sind nicht verfügbar."
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Konto",
|
||||
|
||||
@@ -4433,6 +4433,11 @@
|
||||
"system": "System"
|
||||
}
|
||||
},
|
||||
"sharedRecord": {
|
||||
"unavailableTitle": "Unavailable in this shared record",
|
||||
"unavailableDescription": "This Settings area is intentionally unavailable while viewing another person's record.",
|
||||
"integrationStatusDescription": "Connection status for this managed record. Connection controls are unavailable."
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Account",
|
||||
|
||||
@@ -4433,6 +4433,11 @@
|
||||
"system": "Sistema"
|
||||
}
|
||||
},
|
||||
"sharedRecord": {
|
||||
"unavailableTitle": "No disponible en este registro compartido",
|
||||
"unavailableDescription": "Esta sección de Ajustes no está disponible intencionadamente mientras consultas el registro de otra persona.",
|
||||
"integrationStatusDescription": "Estado de conexión de este registro gestionado. Los controles de conexión no están disponibles."
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Cuenta",
|
||||
|
||||
@@ -4433,6 +4433,11 @@
|
||||
"system": "Système"
|
||||
}
|
||||
},
|
||||
"sharedRecord": {
|
||||
"unavailableTitle": "Indisponible dans ce dossier partagé",
|
||||
"unavailableDescription": "Cette section des réglages est volontairement indisponible lorsque vous consultez le dossier d’une autre personne.",
|
||||
"integrationStatusDescription": "État de connexion pour ce dossier géré. Les commandes de connexion ne sont pas disponibles."
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Compte",
|
||||
|
||||
@@ -4433,6 +4433,11 @@
|
||||
"system": "Sistema"
|
||||
}
|
||||
},
|
||||
"sharedRecord": {
|
||||
"unavailableTitle": "Non disponibile in questo record condiviso",
|
||||
"unavailableDescription": "Questa area delle Impostazioni non è disponibile intenzionalmente mentre visualizzi il record di un’altra persona.",
|
||||
"integrationStatusDescription": "Stato di connessione per questo record gestito. I controlli di connessione non sono disponibili."
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Account",
|
||||
|
||||
@@ -4433,6 +4433,11 @@
|
||||
"system": "System"
|
||||
}
|
||||
},
|
||||
"sharedRecord": {
|
||||
"unavailableTitle": "Niedostępne w tym udostępnionym rekordzie",
|
||||
"unavailableDescription": "Ten obszar Ustawień jest celowo niedostępny podczas przeglądania rekordu innej osoby.",
|
||||
"integrationStatusDescription": "Stan połączenia dla tego zarządzanego rekordu. Elementy sterujące połączeniem są niedostępne."
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Konto",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
SETTINGS_DESTINATION_INVENTORY,
|
||||
} from "@/lib/record-settings/classification";
|
||||
import { toRecordSettingsDto } from "@/lib/record-settings/dto";
|
||||
import { resolveManagedIntegrationState } from "@/lib/record-settings/integrations";
|
||||
import { recordSettingsKeys } from "@/lib/query-keys";
|
||||
|
||||
describe("record settings contract", () => {
|
||||
@@ -36,6 +37,11 @@ describe("record settings contract", () => {
|
||||
"record-1",
|
||||
"detail",
|
||||
]);
|
||||
expect(recordSettingsKeys.recordSettingsIntegrations("record-1")).toEqual([
|
||||
"record-settings",
|
||||
"record-1",
|
||||
"integrations",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not let an adult MANAGE grant use Guardian-only configuration", () => {
|
||||
@@ -66,4 +72,28 @@ describe("record settings contract", () => {
|
||||
expect(isGuardianSettingsWriteAllowed("integrations")).toBe(false);
|
||||
expect(isGuardianSettingsWriteAllowed("ai")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not report a synthetic ledger row as a managed connection", () => {
|
||||
const connected = {
|
||||
withings: false,
|
||||
whoop: false,
|
||||
fitbit: false,
|
||||
nightscout: false,
|
||||
polar: false,
|
||||
oura: false,
|
||||
"google-health": false,
|
||||
strava: false,
|
||||
} as const;
|
||||
|
||||
expect(
|
||||
resolveManagedIntegrationState("connected", connected, "withings"),
|
||||
).toBe("disconnected");
|
||||
expect(
|
||||
resolveManagedIntegrationState(
|
||||
"error_reauth",
|
||||
{ ...connected, oura: true },
|
||||
"oura",
|
||||
),
|
||||
).toBe("error_reauth");
|
||||
});
|
||||
});
|
||||
@@ -6,19 +6,34 @@ import { describe, expect, it } from "vitest";
|
||||
const fromRoot = (...segments: string[]) => resolve(process.cwd(), ...segments);
|
||||
|
||||
describe("managed record settings UI", () => {
|
||||
it("renders integration status without connection controls", async () => {
|
||||
const [statusView, settingsPage] = await Promise.all([
|
||||
readFile(
|
||||
fromRoot("src/components/settings/managed-integration-status.tsx"),
|
||||
"utf8",
|
||||
),
|
||||
readFile(fromRoot("src/app/settings/[section]/page.tsx"), "utf8"),
|
||||
]);
|
||||
it("gates shared deep links and renders integration status without controls", async () => {
|
||||
const [statusView, settingsPage, sectionGate, ...localeBundles] =
|
||||
await Promise.all([
|
||||
readFile(
|
||||
fromRoot("src/components/settings/managed-integration-status.tsx"),
|
||||
"utf8",
|
||||
),
|
||||
readFile(fromRoot("src/app/settings/[section]/page.tsx"), "utf8"),
|
||||
readFile(
|
||||
fromRoot("src/components/settings/record-settings-section-gate.tsx"),
|
||||
"utf8",
|
||||
),
|
||||
...["de", "en", "es", "fr", "it", "pl"].map((locale) =>
|
||||
readFile(fromRoot(`messages/${locale}.json`), "utf8"),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(statusView).toContain("recordSettingsIntegrations");
|
||||
expect(statusView).toContain("/api/record-settings/integrations");
|
||||
expect(statusView).not.toContain("<Button");
|
||||
expect(statusView).not.toContain("<Link");
|
||||
expect(settingsPage).toContain("RecordSettingsSectionGate");
|
||||
expect(sectionGate).toContain("if (!inSharedRecord) return children");
|
||||
expect(sectionGate).toContain("ManagedIntegrationStatus");
|
||||
for (const bundle of localeBundles) {
|
||||
expect(bundle).toContain('"sharedRecord"');
|
||||
expect(bundle).toContain('"unavailableTitle"');
|
||||
expect(bundle).toContain('"integrationStatusDescription"');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,16 @@ import {
|
||||
SharingAccessDeniedError,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
getIntegrationStatus,
|
||||
type IntegrationKey,
|
||||
} from "@/lib/integrations/status";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveGuardianRecordSettingsAccess } from "@/lib/record-settings";
|
||||
import {
|
||||
resolveGuardianRecordSettingsAccess,
|
||||
resolveManagedIntegrationState,
|
||||
} from "@/lib/record-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -33,17 +37,49 @@ export const GET = apiHandler(async () => {
|
||||
const context = await requireGuardianAuth();
|
||||
const access = resolveGuardianRecordSettingsAccess(context);
|
||||
if (!access) throw new SharingAccessDeniedError();
|
||||
const integrations = await Promise.all(
|
||||
MANAGED_INTEGRATIONS.map(async (integration) => {
|
||||
const status = await getIntegrationStatus(access.recordId, integration);
|
||||
return {
|
||||
integration: status.integration,
|
||||
state: status.state,
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
};
|
||||
const [statuses, withings, whoop, fitbit, googleHealth] = await Promise.all([
|
||||
Promise.all(
|
||||
MANAGED_INTEGRATIONS.map((integration) =>
|
||||
getIntegrationStatus(access.recordId, integration),
|
||||
),
|
||||
),
|
||||
prisma.withingsConnection.findUnique({
|
||||
where: { userId: access.recordId },
|
||||
select: { id: true },
|
||||
}),
|
||||
);
|
||||
prisma.whoopConnection.findUnique({
|
||||
where: { userId: access.recordId },
|
||||
select: { whoopUserId: true },
|
||||
}),
|
||||
prisma.fitbitConnection.findUnique({
|
||||
where: { userId: access.recordId },
|
||||
select: { id: true },
|
||||
}),
|
||||
prisma.googleHealthConnection.findUnique({
|
||||
where: { userId: access.recordId },
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
const connected: Record<IntegrationKey, boolean> = {
|
||||
withings: withings !== null,
|
||||
whoop: whoop?.whoopUserId != null,
|
||||
fitbit: fitbit !== null,
|
||||
"google-health": googleHealth !== null,
|
||||
nightscout: context.user.nightscoutUrlEncrypted !== null,
|
||||
polar: context.user.polarAccessTokenEncrypted !== null,
|
||||
oura: context.user.ouraAccessTokenEncrypted !== null,
|
||||
strava: context.user.stravaAccessTokenEncrypted !== null,
|
||||
};
|
||||
const integrations = statuses.map((status) => ({
|
||||
integration: status.integration,
|
||||
state: resolveManagedIntegrationState(
|
||||
status.state,
|
||||
connected,
|
||||
status.integration,
|
||||
),
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
}));
|
||||
|
||||
annotate({ action: { name: "record-settings.integrations.get" } });
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
type SettingsSectionSlug,
|
||||
} from "@/components/settings/section-slugs";
|
||||
import { SettingsShell } from "@/components/settings/settings-shell";
|
||||
import { RecordSettingsSectionGate } from "@/components/settings/record-settings-section-gate";
|
||||
|
||||
/**
|
||||
* Dynamic settings section route. Each of the `SETTINGS_SECTION_SLUGS`
|
||||
@@ -128,5 +129,11 @@ export default async function SettingsSectionPage({ params }: PageProps) {
|
||||
<SectionPlaceholder slug={section} />
|
||||
);
|
||||
|
||||
return <SettingsShell active={section}>{body}</SettingsShell>;
|
||||
return (
|
||||
<SettingsShell active={section}>
|
||||
<RecordSettingsSectionGate section={section}>
|
||||
{body}
|
||||
</RecordSettingsSectionGate>
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Plug } from "lucide-react";
|
||||
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { IntegrationStatusPill } from "@/components/settings/integration-status-pill";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
type ManagedIntegrationState =
|
||||
"connected" | "error_transient" | "error_reauth" | "disconnected" | "parked";
|
||||
|
||||
interface ManagedIntegrationStatusResponse {
|
||||
recordId: string;
|
||||
integrations: Array<{
|
||||
integration: keyof typeof INTEGRATION_DISPLAY_NAMES;
|
||||
state: ManagedIntegrationState;
|
||||
lastSuccessAt: string | null;
|
||||
lastAttemptAt: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
const INTEGRATION_DISPLAY_NAMES = {
|
||||
withings: "Withings",
|
||||
whoop: "WHOOP",
|
||||
fitbit: "Fitbit",
|
||||
nightscout: "Nightscout",
|
||||
polar: "Polar",
|
||||
oura: "Oura",
|
||||
"google-health": "Google Health",
|
||||
strava: "Strava",
|
||||
} as const;
|
||||
|
||||
function recordStatusPillState(state: ManagedIntegrationState) {
|
||||
switch (state) {
|
||||
case "connected":
|
||||
return "connected" as const;
|
||||
case "error_transient":
|
||||
return "warning" as const;
|
||||
case "error_reauth":
|
||||
return "error" as const;
|
||||
case "parked":
|
||||
return "parked" as const;
|
||||
case "disconnected":
|
||||
return "disconnected" as const;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The managed-record integration view is deliberately a status projection.
|
||||
* It reads the active record's id from the resolved account-access payload and
|
||||
* has no connection, credential, sync, or provider-control affordance.
|
||||
*/
|
||||
export function ManagedIntegrationStatus() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const activeRecord = user?.accountAccess?.active ?? null;
|
||||
const recordId = activeRecord?.accountId ?? null;
|
||||
const statusQuery = useQuery({
|
||||
queryKey: queryKeys.recordSettingsIntegrations(recordId ?? ""),
|
||||
queryFn: () =>
|
||||
apiGet<ManagedIntegrationStatusResponse>(
|
||||
"/api/record-settings/integrations",
|
||||
),
|
||||
enabled: activeRecord?.recordKind === "managed" && recordId !== null,
|
||||
});
|
||||
|
||||
if (statusQuery.isError) {
|
||||
return <QueryErrorCard onRetry={() => void statusQuery.refetch()} />;
|
||||
}
|
||||
|
||||
if (statusQuery.isLoading || !statusQuery.data) {
|
||||
return (
|
||||
<SettingsCard aria-busy="true" aria-live="polite">
|
||||
<SettingsCardHeader
|
||||
icon={Plug}
|
||||
title={t("settings.sections.integrations.title")}
|
||||
description={t("settings.sharedRecord.integrationStatusDescription")}
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard aria-labelledby="managed-integration-status-title">
|
||||
<SettingsCardHeader
|
||||
icon={Plug}
|
||||
title={t("settings.sections.integrations.title")}
|
||||
titleId="managed-integration-status-title"
|
||||
description={t("settings.sharedRecord.integrationStatusDescription")}
|
||||
/>
|
||||
<ul
|
||||
className="mt-4 space-y-3"
|
||||
aria-label={activeRecord?.displayName ?? undefined}
|
||||
>
|
||||
{statusQuery.data.integrations.map((integration) => (
|
||||
<li
|
||||
key={integration.integration}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{INTEGRATION_DISPLAY_NAMES[integration.integration]}
|
||||
</span>
|
||||
<IntegrationStatusPill
|
||||
state={recordStatusPillState(integration.state)}
|
||||
lastSyncAt={
|
||||
integration.lastSuccessAt ?? integration.lastAttemptAt
|
||||
}
|
||||
testId={`managed-integration-${integration.integration}`}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { CircleSlash } from "lucide-react";
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { classifySettingsDestination } from "@/lib/record-settings";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ManagedIntegrationStatus } from "@/components/settings/managed-integration-status";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import type { SettingsSectionSlug } from "@/components/settings/section-slugs";
|
||||
|
||||
/**
|
||||
* Stops actor-scoped Settings components from mounting while the browser is
|
||||
* switched into another record. The only managed integration surface is its
|
||||
* status projection; all other direct links resolve to a local refusal state.
|
||||
*/
|
||||
export function RecordSettingsSectionGate({
|
||||
section,
|
||||
children,
|
||||
}: {
|
||||
section: SettingsSectionSlug;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { inSharedRecord, recordKind } = useRecordCapabilities();
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (!inSharedRecord) return children;
|
||||
|
||||
const destination = classifySettingsDestination(section);
|
||||
if (
|
||||
recordKind === "managed" &&
|
||||
destination.kind === "managed-guardian" &&
|
||||
section === "integrations"
|
||||
) {
|
||||
return <ManagedIntegrationStatus />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard aria-labelledby="shared-record-settings-unavailable-title">
|
||||
<SettingsCardHeader
|
||||
icon={CircleSlash}
|
||||
title={t("settings.sharedRecord.unavailableTitle")}
|
||||
titleId="shared-record-settings-unavailable-title"
|
||||
description={t("settings.sharedRecord.unavailableDescription")}
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -12,4 +12,11 @@ export const recordSettingsKeys = {
|
||||
["record-settings", recordId, "integrations"] as const,
|
||||
profile: (recordId: string) =>
|
||||
["record-settings", recordId, "profile"] as const,
|
||||
/** Named aliases stay unambiguous when assembled into `queryKeys`. */
|
||||
recordSettingsDetail: (recordId: string) =>
|
||||
["record-settings", recordId, "detail"] as const,
|
||||
recordSettingsIntegrations: (recordId: string) =>
|
||||
["record-settings", recordId, "integrations"] as const,
|
||||
recordSettingsProfile: (recordId: string) =>
|
||||
["record-settings", recordId, "profile"] as const,
|
||||
};
|
||||
@@ -1,7 +1,4 @@
|
||||
export type RecordSettingsRelationship =
|
||||
| "guardian"
|
||||
| "self"
|
||||
| "adult-manager";
|
||||
export type RecordSettingsRelationship = "guardian" | "self" | "adult-manager";
|
||||
|
||||
export interface RecordSettingsAccess {
|
||||
actorId: string;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { SettingsSectionSlug } from "@/components/settings/section-slugs";
|
||||
|
||||
export type SettingsDestinationKind =
|
||||
| "personal"
|
||||
| "managed-guardian"
|
||||
| "adult-shared-unavailable"
|
||||
| "unavailable";
|
||||
"personal" | "managed-guardian" | "adult-shared-unavailable" | "unavailable";
|
||||
|
||||
export interface SettingsDestinationClassification {
|
||||
kind: SettingsDestinationKind;
|
||||
@@ -72,15 +69,17 @@ export const SETTINGS_DESTINATION_INVENTORY = {
|
||||
advanced: PERSONAL,
|
||||
privacy: PERSONAL,
|
||||
about: PERSONAL,
|
||||
} as const satisfies Record<SettingsSectionSlug, SettingsDestinationClassification>;
|
||||
} as const satisfies Record<
|
||||
SettingsSectionSlug,
|
||||
SettingsDestinationClassification
|
||||
>;
|
||||
|
||||
export function classifySettingsDestination(
|
||||
destination: string,
|
||||
): SettingsDestinationClassification {
|
||||
return (
|
||||
SETTINGS_DESTINATION_INVENTORY[
|
||||
destination as SettingsSectionSlug
|
||||
] ?? UNAVAILABLE
|
||||
SETTINGS_DESTINATION_INVENTORY[destination as SettingsSectionSlug] ??
|
||||
UNAVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
type RecordSettingsAccess,
|
||||
} from "./access";
|
||||
export { toRecordSettingsDto, type RecordSettingsDto } from "./dto";
|
||||
export { resolveManagedIntegrationState } from "./integrations";
|
||||
export {
|
||||
classifySettingsDestination,
|
||||
isGuardianSettingsWriteAllowed,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type {
|
||||
IntegrationKey,
|
||||
IntegrationState,
|
||||
} from "@/lib/integrations/status";
|
||||
|
||||
/**
|
||||
* A status-ledger row alone cannot prove a connection: integrations without a
|
||||
* row synthesize a healthy ledger state for the owner Settings dashboard. The
|
||||
* managed projection must instead report those as disconnected until its
|
||||
* record carries a real credential or connection row.
|
||||
*/
|
||||
export function resolveManagedIntegrationState(
|
||||
state: IntegrationState,
|
||||
connected: Record<IntegrationKey, boolean>,
|
||||
integration: IntegrationKey,
|
||||
): IntegrationState {
|
||||
return connected[integration] ? state : "disconnected";
|
||||
}
|
||||
Reference in new issue
Block a user