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:
Marc-André Bombeck committed 2026-08-06 02:38:54 +02:00
1 parent 54cd3e3959
commit 03ee01ba56
17 files changed
+343 -32

No files matched your search

+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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");
});
});
+23 -8
View File
@@ -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" } });
+8 -1
View File
@@ -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>
);
}
+7
View File
@@ -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 -4
View File
@@ -1,7 +1,4 @@
export type RecordSettingsRelationship =
| "guardian"
| "self"
| "adult-manager";
export type RecordSettingsRelationship = "guardian" | "self" | "adult-manager";
export interface RecordSettingsAccess {
actorId: string;
+7 -8
View File
@@ -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
);
}
+1
View File
@@ -4,6 +4,7 @@ export {
type RecordSettingsAccess,
} from "./access";
export { toRecordSettingsDto, type RecordSettingsDto } from "./dto";
export { resolveManagedIntegrationState } from "./integrations";
export {
classifySettingsDestination,
isGuardianSettingsWriteAllowed,
+18
View File
@@ -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";
}