From 03ee01ba56ebf0a4385262a97066d1df26f90f2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 02:38:54 +0200 Subject: [PATCH] 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 --- messages/de.json | 5 + messages/en.json | 5 + messages/es.json | 5 + messages/fr.json | 5 + messages/it.json | 5 + messages/pl.json | 5 + .../record-settings-contract.test.ts | 30 +++++ src/__tests__/record-settings-ui.test.tsx | 31 +++-- .../api/record-settings/integrations/route.ts | 58 +++++++-- src/app/settings/[section]/page.tsx | 9 +- .../settings/managed-integration-status.tsx | 121 ++++++++++++++++++ .../settings/record-settings-section-gate.tsx | 50 ++++++++ src/lib/query-keys/record-settings.ts | 7 + src/lib/record-settings/access.ts | 5 +- src/lib/record-settings/classification.ts | 15 +-- src/lib/record-settings/index.ts | 1 + src/lib/record-settings/integrations.ts | 18 +++ 17 files changed, 343 insertions(+), 32 deletions(-) create mode 100644 src/components/settings/managed-integration-status.tsx create mode 100644 src/components/settings/record-settings-section-gate.tsx create mode 100644 src/lib/record-settings/integrations.ts diff --git a/messages/de.json b/messages/de.json index 388c99a38..faa9ce89e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 48f404d69..b40757598 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index bbd66c8fa..ca5332aed 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 9f68a3ef5..34de11f03 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index c37fdb228..3e2b9d38f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index e97a5d6ea..ad45b25c4 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/__tests__/record-settings-contract.test.ts b/src/__tests__/record-settings-contract.test.ts index b2267601f..71395e9bb 100644 --- a/src/__tests__/record-settings-contract.test.ts +++ b/src/__tests__/record-settings-contract.test.ts @@ -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"); + }); }); diff --git a/src/__tests__/record-settings-ui.test.tsx b/src/__tests__/record-settings-ui.test.tsx index d650a027a..28521092d 100644 --- a/src/__tests__/record-settings-ui.test.tsx +++ b/src/__tests__/record-settings-ui.test.tsx @@ -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(" { 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 = { + 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" } }); diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index 4df2a7c55..ee1f341af 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -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) { ); - return {body}; + return ( + + + {body} + + + ); } diff --git a/src/components/settings/managed-integration-status.tsx b/src/components/settings/managed-integration-status.tsx new file mode 100644 index 000000000..28f3cd367 --- /dev/null +++ b/src/components/settings/managed-integration-status.tsx @@ -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( + "/api/record-settings/integrations", + ), + enabled: activeRecord?.recordKind === "managed" && recordId !== null, + }); + + if (statusQuery.isError) { + return void statusQuery.refetch()} />; + } + + if (statusQuery.isLoading || !statusQuery.data) { + return ( + + + + ); + } + + return ( + + +
    + {statusQuery.data.integrations.map((integration) => ( +
  • + + {INTEGRATION_DISPLAY_NAMES[integration.integration]} + + +
  • + ))} +
+
+ ); +} diff --git a/src/components/settings/record-settings-section-gate.tsx b/src/components/settings/record-settings-section-gate.tsx new file mode 100644 index 000000000..6d91aa71c --- /dev/null +++ b/src/components/settings/record-settings-section-gate.tsx @@ -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 ; + } + + return ( + + + + ); +} diff --git a/src/lib/query-keys/record-settings.ts b/src/lib/query-keys/record-settings.ts index 3f47c26d3..7de37c03b 100644 --- a/src/lib/query-keys/record-settings.ts +++ b/src/lib/query-keys/record-settings.ts @@ -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, }; diff --git a/src/lib/record-settings/access.ts b/src/lib/record-settings/access.ts index ba1f8c79b..024f50671 100644 --- a/src/lib/record-settings/access.ts +++ b/src/lib/record-settings/access.ts @@ -1,7 +1,4 @@ -export type RecordSettingsRelationship = - | "guardian" - | "self" - | "adult-manager"; +export type RecordSettingsRelationship = "guardian" | "self" | "adult-manager"; export interface RecordSettingsAccess { actorId: string; diff --git a/src/lib/record-settings/classification.ts b/src/lib/record-settings/classification.ts index 8c613c52f..9413fd128 100644 --- a/src/lib/record-settings/classification.ts +++ b/src/lib/record-settings/classification.ts @@ -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; +} 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 ); } diff --git a/src/lib/record-settings/index.ts b/src/lib/record-settings/index.ts index 3212a6c57..ba08366e8 100644 --- a/src/lib/record-settings/index.ts +++ b/src/lib/record-settings/index.ts @@ -4,6 +4,7 @@ export { type RecordSettingsAccess, } from "./access"; export { toRecordSettingsDto, type RecordSettingsDto } from "./dto"; +export { resolveManagedIntegrationState } from "./integrations"; export { classifySettingsDestination, isGuardianSettingsWriteAllowed, diff --git a/src/lib/record-settings/integrations.ts b/src/lib/record-settings/integrations.ts new file mode 100644 index 000000000..192dd7e7c --- /dev/null +++ b/src/lib/record-settings/integrations.ts @@ -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, + integration: IntegrationKey, +): IntegrationState { + return connected[integration] ? state : "disconnected"; +}