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

@@ -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>
);
}