mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
refactor(settings): move notification channels into Integrationen
Channels (Telegram, ntfy, Web Push, Webhook, Email) and the live per-channel status surface are delivery providers, not notification preferences, so they now sit under Integrations next to the connected services. Integrations gains a Connections / Channels sub-tab split; Notifications keeps the reminder-type content (mood, low-stock, coach nudge) and cross-links to the relocated channels.
This commit is contained in:
1 parent
4a1971c0dd
commit
d3d8731209
10 files changed
+513
-319
No files matched your search
+15
-2
@@ -3856,11 +3856,24 @@
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integrationen",
|
||||
"description": "Withings, moodLog und weitere verbundene Dienste."
|
||||
"description": "Withings, moodLog und weitere verbundene Dienste.",
|
||||
"tabs": {
|
||||
"connections": "Verbindungen",
|
||||
"channels": "Kanäle",
|
||||
"sources": "Quellen"
|
||||
},
|
||||
"connections": {
|
||||
"description": "Withings, WHOOP, Fitbit, Polar, Oura und Nightscout verbinden."
|
||||
},
|
||||
"channels": {
|
||||
"description": "Telegram, ntfy, Web-Push, Webhook und E-Mail – die Wege, über die Benachrichtigungen zugestellt werden."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
"description": "Live-Übersicht aller konfigurierten Kanäle."
|
||||
"description": "Welche Erinnerungen ausgelöst werden. Die Zustellkanäle liegen unter Integrationen.",
|
||||
"channelsHint": "Telegram, ntfy, Web-Push, Webhook und E-Mail richtest du jetzt unter",
|
||||
"channelsHintLink": "Integrationen → Kanäle"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Layout & Personalisierung",
|
||||
|
||||
+15
-2
@@ -3856,11 +3856,24 @@
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integrations",
|
||||
"description": "Withings, moodLog, and other connected services."
|
||||
"description": "Withings, moodLog, and other connected services.",
|
||||
"tabs": {
|
||||
"connections": "Connections",
|
||||
"channels": "Channels",
|
||||
"sources": "Sources"
|
||||
},
|
||||
"connections": {
|
||||
"description": "Connect Withings, WHOOP, Fitbit, Polar, Oura, and Nightscout."
|
||||
},
|
||||
"channels": {
|
||||
"description": "Telegram, ntfy, Web Push, Webhook, and Email — the routes notifications are delivered through."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"description": "Live overview of every configured channel."
|
||||
"description": "Which reminders fire. The delivery channels live under Integrations.",
|
||||
"channelsHint": "Telegram, ntfy, Web Push, Webhook, and Email are now set up under",
|
||||
"channelsHintLink": "Integrations → Channels"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Layout & Personalization",
|
||||
|
||||
+15
-2
@@ -3856,11 +3856,24 @@
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integraciones",
|
||||
"description": "Withings, moodLog y otros servicios conectados."
|
||||
"description": "Withings, moodLog y otros servicios conectados.",
|
||||
"tabs": {
|
||||
"connections": "Conexiones",
|
||||
"channels": "Canales",
|
||||
"sources": "Fuentes"
|
||||
},
|
||||
"connections": {
|
||||
"description": "Conecta Withings, WHOOP, Fitbit, Polar, Oura y Nightscout."
|
||||
},
|
||||
"channels": {
|
||||
"description": "Telegram, ntfy, Web Push, Webhook y correo: las vías por las que se entregan las notificaciones."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notificaciones",
|
||||
"description": "Vista en vivo de cada canal configurado."
|
||||
"description": "Qué recordatorios se activan. Los canales de entrega están en Integraciones.",
|
||||
"channelsHint": "Telegram, ntfy, Web Push, Webhook y correo se configuran ahora en",
|
||||
"channelsHintLink": "Integraciones → Canales"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Diseño y personalización",
|
||||
|
||||
+15
-2
@@ -3856,11 +3856,24 @@
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Intégrations",
|
||||
"description": "Withings, moodLog et autres services connectés."
|
||||
"description": "Withings, moodLog et autres services connectés.",
|
||||
"tabs": {
|
||||
"connections": "Connexions",
|
||||
"channels": "Canaux",
|
||||
"sources": "Sources"
|
||||
},
|
||||
"connections": {
|
||||
"description": "Connecter Withings, WHOOP, Fitbit, Polar, Oura et Nightscout."
|
||||
},
|
||||
"channels": {
|
||||
"description": "Telegram, ntfy, Web Push, Webhook et e-mail : les voies par lesquelles les notifications sont envoyées."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"description": "Aperçu en direct de chaque canal configuré."
|
||||
"description": "Quels rappels se déclenchent. Les canaux de diffusion se trouvent dans Intégrations.",
|
||||
"channelsHint": "Telegram, ntfy, Web Push, Webhook et e-mail se configurent désormais dans",
|
||||
"channelsHintLink": "Intégrations → Canaux"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Disposition et personnalisation",
|
||||
|
||||
+15
-2
@@ -3856,11 +3856,24 @@
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integrazioni",
|
||||
"description": "Withings, moodLog e altri servizi collegati."
|
||||
"description": "Withings, moodLog e altri servizi collegati.",
|
||||
"tabs": {
|
||||
"connections": "Connessioni",
|
||||
"channels": "Canali",
|
||||
"sources": "Origini"
|
||||
},
|
||||
"connections": {
|
||||
"description": "Collega Withings, WHOOP, Fitbit, Polar, Oura e Nightscout."
|
||||
},
|
||||
"channels": {
|
||||
"description": "Telegram, ntfy, Web Push, Webhook ed e-mail: le vie attraverso cui vengono recapitate le notifiche."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifiche",
|
||||
"description": "Panoramica in tempo reale di ogni canale configurato."
|
||||
"description": "Quali promemoria vengono attivati. I canali di recapito si trovano in Integrazioni.",
|
||||
"channelsHint": "Telegram, ntfy, Web Push, Webhook ed e-mail ora si configurano in",
|
||||
"channelsHintLink": "Integrazioni → Canali"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Layout e personalizzazione",
|
||||
|
||||
+15
-2
@@ -3856,11 +3856,24 @@
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integracje",
|
||||
"description": "Withings, moodLog i inne połączone usługi."
|
||||
"description": "Withings, moodLog i inne połączone usługi.",
|
||||
"tabs": {
|
||||
"connections": "Połączenia",
|
||||
"channels": "Kanały",
|
||||
"sources": "Źródła"
|
||||
},
|
||||
"connections": {
|
||||
"description": "Połącz Withings, WHOOP, Fitbit, Polar, Oura i Nightscout."
|
||||
},
|
||||
"channels": {
|
||||
"description": "Telegram, ntfy, Web Push, Webhook i e-mail — drogi dostarczania powiadomień."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Powiadomienia",
|
||||
"description": "Podgląd na żywo każdego skonfigurowanego kanału."
|
||||
"description": "Które przypomnienia są uruchamiane. Kanały dostarczania znajdują się w Integracjach.",
|
||||
"channelsHint": "Telegram, ntfy, Web Push, Webhook i e-mail konfigurujesz teraz w",
|
||||
"channelsHintLink": "Integracje → Kanały"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Układ i personalizacja",
|
||||
|
||||
@@ -1,234 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
/**
|
||||
* `<IntegrationsSection>` — Settings → Integrations.
|
||||
*
|
||||
* v1.18.0 (S3) — Integrations is now the one home for every delivery / ingest
|
||||
* connection. Three sub-tabs under one nav entry:
|
||||
* 1. Connections — Withings / WHOOP / Fitbit / Polar / Oura / Nightscout
|
||||
* (the OAuth + device data sources). See `connections-panel.tsx`.
|
||||
* 2. Channels — Telegram / ntfy / Web Push / Webhook / Email plus the live
|
||||
* per-channel status surface (moved here from Settings → Notifications,
|
||||
* where it never belonged: channels are delivery providers, not
|
||||
* reminder preferences). See `notification-channels-panel.tsx`.
|
||||
*
|
||||
* `parseOAuthOutcome` / `oauthReasonKey` are re-exported from
|
||||
* `connections-panel.tsx` so existing unit-test imports keep resolving.
|
||||
*/
|
||||
|
||||
import { FitbitCard } from "@/components/settings/integrations/fitbit-card";
|
||||
import { NightscoutCard } from "@/components/settings/integrations/nightscout-card";
|
||||
import type { OAuthProviderStatus } from "@/components/settings/integrations/oauth-provider-card";
|
||||
import { OuraCard } from "@/components/settings/integrations/oura-card";
|
||||
import { PolarCard } from "@/components/settings/integrations/polar-card";
|
||||
import {
|
||||
pickStatus,
|
||||
useIntegrationStatuses,
|
||||
type IntegrationStatusViewModel,
|
||||
} from "@/components/settings/integrations/shared";
|
||||
import { WhoopCard } from "@/components/settings/integrations/whoop-card";
|
||||
import { WithingsCard } from "@/components/settings/integrations/withings-card";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ConnectionsPanel } from "@/components/settings/integrations/connections-panel";
|
||||
import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* The Withings OAuth callback (`/api/withings/callback`) redirects back
|
||||
* here with `?withings=connected` or `?withings=error&reason=<tag>`.
|
||||
* Map every reason tag the callback emits onto a human-readable i18n
|
||||
* key (what went wrong + what to do next). Unknown tags fall back to
|
||||
* the generic entry so a future callback branch never strands the user
|
||||
* with silent params.
|
||||
*/
|
||||
const WITHINGS_OAUTH_ERROR_KEYS: Record<string, string> = {
|
||||
csrf1: "settings.withingsOauthError.csrf1",
|
||||
replay: "settings.withingsOauthError.replay",
|
||||
state: "settings.withingsOauthError.state",
|
||||
expired: "settings.withingsOauthError.expired",
|
||||
cross_user: "settings.withingsOauthError.cross_user",
|
||||
nocode: "settings.withingsOauthError.nocode",
|
||||
nocreds: "settings.withingsOauthError.nocreds",
|
||||
token: "settings.withingsOauthError.token",
|
||||
};
|
||||
export {
|
||||
parseOAuthOutcome,
|
||||
oauthReasonKey,
|
||||
} from "@/components/settings/integrations/connections-panel";
|
||||
|
||||
type WithingsOauthOutcome =
|
||||
| { kind: "connected" }
|
||||
| { kind: "error"; reason: string };
|
||||
|
||||
/**
|
||||
* The OAuth providers whose callbacks redirect back to the settings page with a
|
||||
* `?<provider>=connected|error&reason=<tag>` outcome param. Polar/Oura own a
|
||||
* per-card status query; WHOOP/Fitbit read off the consolidated envelope — both
|
||||
* are invalidated on a successful return so the card repaints either way.
|
||||
*/
|
||||
const OAUTH_OUTCOME_PROVIDERS = ["polar", "oura", "whoop", "fitbit"] as const;
|
||||
type OAuthOutcomeProvider = (typeof OAUTH_OUTCOME_PROVIDERS)[number];
|
||||
|
||||
const OAUTH_OUTCOME_KEYS: Record<
|
||||
OAuthOutcomeProvider,
|
||||
() => readonly unknown[]
|
||||
> = {
|
||||
polar: queryKeys.polar,
|
||||
oura: queryKeys.oura,
|
||||
whoop: queryKeys.whoop,
|
||||
fitbit: queryKeys.fitbit,
|
||||
};
|
||||
|
||||
/**
|
||||
* Reason tags the four OAuth callbacks emit. Known tags resolve to a specific
|
||||
* message; anything else falls back to the provider's `generic` copy. Union of
|
||||
* the Polar/Oura set (`rate_limited`) and the WHOOP/Fitbit set (`expired`).
|
||||
*/
|
||||
const OAUTH_OUTCOME_REASONS = new Set([
|
||||
"csrf1",
|
||||
"state",
|
||||
"cross_user",
|
||||
"nocode",
|
||||
"nocreds",
|
||||
"token",
|
||||
"rate_limited",
|
||||
"expired",
|
||||
]);
|
||||
|
||||
type OAuthOutcome =
|
||||
| { provider: OAuthOutcomeProvider; kind: "connected" }
|
||||
| { provider: OAuthOutcomeProvider; kind: "error"; reason: string };
|
||||
|
||||
/**
|
||||
* Parse the OAuth-return outcome from a URL query string. Reads the first
|
||||
* provider whose `?<provider>=connected|error` param is present, in
|
||||
* `OAUTH_OUTCOME_PROVIDERS` order. Pure + exported so the four-provider
|
||||
* coverage is unit-testable without a browser.
|
||||
*/
|
||||
export function parseOAuthOutcome(search: string): OAuthOutcome | null {
|
||||
const params = new URLSearchParams(search);
|
||||
for (const provider of OAUTH_OUTCOME_PROVIDERS) {
|
||||
const v = params.get(provider);
|
||||
if (v === "connected") return { provider, kind: "connected" };
|
||||
if (v === "error") {
|
||||
return { provider, kind: "error", reason: params.get("reason") ?? "unknown" };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the i18n key for an error reason tag. Known tags map to the
|
||||
* provider-specific message; anything else falls back to `generic`.
|
||||
*/
|
||||
export function oauthReasonKey(
|
||||
provider: OAuthOutcomeProvider,
|
||||
reason: string,
|
||||
): string {
|
||||
return OAUTH_OUTCOME_REASONS.has(reason)
|
||||
? `settings.${provider}OauthError.${reason}`
|
||||
: `settings.${provider}OauthError.generic`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt the consolidated-envelope view-model into the OAuth card's status
|
||||
* shape. Returns `undefined` when the envelope hasn't loaded so the card
|
||||
* renders its loading/disconnected default rather than a half-populated state.
|
||||
*/
|
||||
function toOAuthStatus(
|
||||
vm: IntegrationStatusViewModel | undefined,
|
||||
): OAuthProviderStatus | undefined {
|
||||
if (!vm) return undefined;
|
||||
return {
|
||||
connected: vm.connected ?? false,
|
||||
configured: vm.configured ?? false,
|
||||
available: vm.available ?? false,
|
||||
hasOwnCredentials: vm.hasOwnCredentials,
|
||||
state: vm.state,
|
||||
lastSuccessAt: vm.lastSuccessAt,
|
||||
lastAttemptAt: vm.lastAttemptAt,
|
||||
lastError: vm.lastError,
|
||||
};
|
||||
}
|
||||
type IntegrationsTab = "connections" | "channels";
|
||||
|
||||
export function IntegrationsSection() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated);
|
||||
|
||||
// OAuth callback handler — reads `?withings=connected|error&reason=…`
|
||||
// from the URL (lazy initialiser, same shape as the Codex handler in
|
||||
// `ai-section.tsx`) and surfaces the outcome as a toast. Pre-fix the
|
||||
// callback set these params and nothing ever read them: a user came
|
||||
// back from Withings onto a silently unchanged settings page.
|
||||
const [withingsOauthOutcome] = useState<WithingsOauthOutcome | null>(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const status = params.get("withings");
|
||||
if (status === "connected") return { kind: "connected" };
|
||||
if (status === "error") {
|
||||
return { kind: "error", reason: params.get("reason") ?? "unknown" };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!withingsOauthOutcome) return;
|
||||
// Scrub the one-shot params so a reload / bookmark doesn't replay
|
||||
// the toast.
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("withings");
|
||||
url.searchParams.delete("reason");
|
||||
router.replace(`${url.pathname}${url.search}`, { scroll: false });
|
||||
if (withingsOauthOutcome.kind === "connected") {
|
||||
toast.success(t("settings.withingsOauthConnected"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
} else {
|
||||
const reasonKey =
|
||||
WITHINGS_OAUTH_ERROR_KEYS[withingsOauthOutcome.reason] ??
|
||||
"settings.withingsOauthError.generic";
|
||||
toast.error(t("settings.withingsOauthFailed"), {
|
||||
description: t(reasonKey),
|
||||
duration: 10_000,
|
||||
});
|
||||
}
|
||||
}, [withingsOauthOutcome, router, queryClient, t]);
|
||||
|
||||
// v1.17.0 (F4) — generic OAuth-callback toast for the OAuth providers.
|
||||
// The Polar / Oura / WHOOP / Fitbit callbacks redirect back with
|
||||
// `?<provider>=connected` or `?<provider>=error&reason=<tag>`; surface the
|
||||
// outcome as a toast and scrub the one-shot params so a reload doesn't replay
|
||||
// it. Pre-v1.17.1 only Polar/Oura were read here — a user returning from a
|
||||
// WHOOP or Fitbit round-trip landed on a silently unchanged settings page,
|
||||
// the same gap the Withings handler was written to close.
|
||||
const [oauthOutcome] = useState<OAuthOutcome | null>(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
return parseOAuthOutcome(window.location.search);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!oauthOutcome) return;
|
||||
const { provider } = oauthOutcome;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete(provider);
|
||||
url.searchParams.delete("reason");
|
||||
router.replace(`${url.pathname}${url.search}`, { scroll: false });
|
||||
if (oauthOutcome.kind === "connected") {
|
||||
toast.success(t(`settings.${provider}OauthConnected`));
|
||||
// Polar/Oura own a per-card status query; WHOOP/Fitbit read off the
|
||||
// consolidated envelope — invalidate both so the card repaints either way.
|
||||
queryClient.invalidateQueries({ queryKey: OAUTH_OUTCOME_KEYS[provider]() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
} else {
|
||||
// Known reason tags resolve to a specific message; anything else falls
|
||||
// back to the generic copy (matching the Withings handler).
|
||||
toast.error(t(`settings.${provider}OauthFailed`), {
|
||||
description: t(oauthReasonKey(provider, oauthOutcome.reason)),
|
||||
duration: 10_000,
|
||||
});
|
||||
}
|
||||
}, [oauthOutcome, router, queryClient, t]);
|
||||
|
||||
const withingsViewModel = pickStatus(integrationStatus, "withings");
|
||||
const whoopViewModel = pickStatus(integrationStatus, "whoop");
|
||||
const fitbitViewModel = pickStatus(integrationStatus, "fitbit");
|
||||
// v1.17.1 — Polar/Oura now read off the same consolidated envelope; the cards
|
||||
// no longer fire their own /api/<provider>/status round-trip.
|
||||
const polarViewModel = toOAuthStatus(pickStatus(integrationStatus, "polar"));
|
||||
const ouraViewModel = toOAuthStatus(pickStatus(integrationStatus, "oura"));
|
||||
const [tab, setTab] = useState<IntegrationsTab>("connections");
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -242,28 +46,28 @@ export function IntegrationsSection() {
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.integrations.description")}
|
||||
</p>
|
||||
{/* Cross-link to Settings → Sources: when two integrations (or
|
||||
an integration + manual entry) report the same metric, the
|
||||
source-priority ladder decides which value counts — a fact
|
||||
newcomers otherwise discover only after a confusing chart. */}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.integrationsSourcesHint")}{" "}
|
||||
<Link
|
||||
href="/settings/sources"
|
||||
className="text-primary underline underline-offset-2"
|
||||
data-slot="integrations-sources-cross-link"
|
||||
>
|
||||
{t("settings.integrationsSourcesHintLink")}
|
||||
</Link>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<WithingsCard viewModel={withingsViewModel} />
|
||||
<WhoopCard viewModel={whoopViewModel} />
|
||||
<FitbitCard viewModel={fitbitViewModel} />
|
||||
<PolarCard enabled={isAuthenticated} viewModel={polarViewModel} />
|
||||
<OuraCard enabled={isAuthenticated} viewModel={ouraViewModel} />
|
||||
<NightscoutCard enabled={isAuthenticated} />
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(value) => setTab(value as IntegrationsTab)}
|
||||
>
|
||||
<TabsList className="w-full sm:w-fit">
|
||||
<TabsTrigger value="connections">
|
||||
{t("settings.sections.integrations.tabs.connections")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="channels">
|
||||
{t("settings.sections.integrations.tabs.channels")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="connections">
|
||||
<ConnectionsPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="channels">
|
||||
<NotificationChannelsPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<ConnectionsPanel>` — the connected-services half of Settings →
|
||||
* Integrations: Withings / WHOOP / Fitbit / Polar / Oura / Nightscout.
|
||||
*
|
||||
* v1.18.0 (S3) — extracted out of `integrations-section.tsx` so the section
|
||||
* file can host the Connections / Channels / Sources sub-tabs without growing
|
||||
* unbounded. This panel owns the OAuth-callback toast handlers (the four
|
||||
* providers + Withings redirect back here with `?<provider>=…` params) and the
|
||||
* card grid. It renders no page header of its own; the parent supplies it.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { FitbitCard } from "@/components/settings/integrations/fitbit-card";
|
||||
import { NightscoutCard } from "@/components/settings/integrations/nightscout-card";
|
||||
import type { OAuthProviderStatus } from "@/components/settings/integrations/oauth-provider-card";
|
||||
import { OuraCard } from "@/components/settings/integrations/oura-card";
|
||||
import { PolarCard } from "@/components/settings/integrations/polar-card";
|
||||
import {
|
||||
pickStatus,
|
||||
useIntegrationStatuses,
|
||||
type IntegrationStatusViewModel,
|
||||
} from "@/components/settings/integrations/shared";
|
||||
import { WhoopCard } from "@/components/settings/integrations/whoop-card";
|
||||
import { WithingsCard } from "@/components/settings/integrations/withings-card";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* The Withings OAuth callback (`/api/withings/callback`) redirects back
|
||||
* here with `?withings=connected` or `?withings=error&reason=<tag>`.
|
||||
* Map every reason tag the callback emits onto a human-readable i18n
|
||||
* key (what went wrong + what to do next). Unknown tags fall back to
|
||||
* the generic entry so a future callback branch never strands the user
|
||||
* with silent params.
|
||||
*/
|
||||
const WITHINGS_OAUTH_ERROR_KEYS: Record<string, string> = {
|
||||
csrf1: "settings.withingsOauthError.csrf1",
|
||||
replay: "settings.withingsOauthError.replay",
|
||||
state: "settings.withingsOauthError.state",
|
||||
expired: "settings.withingsOauthError.expired",
|
||||
cross_user: "settings.withingsOauthError.cross_user",
|
||||
nocode: "settings.withingsOauthError.nocode",
|
||||
nocreds: "settings.withingsOauthError.nocreds",
|
||||
token: "settings.withingsOauthError.token",
|
||||
};
|
||||
|
||||
type WithingsOauthOutcome =
|
||||
| { kind: "connected" }
|
||||
| { kind: "error"; reason: string };
|
||||
|
||||
/**
|
||||
* The OAuth providers whose callbacks redirect back to the settings page with a
|
||||
* `?<provider>=connected|error&reason=<tag>` outcome param. Polar/Oura own a
|
||||
* per-card status query; WHOOP/Fitbit read off the consolidated envelope — both
|
||||
* are invalidated on a successful return so the card repaints either way.
|
||||
*/
|
||||
const OAUTH_OUTCOME_PROVIDERS = ["polar", "oura", "whoop", "fitbit"] as const;
|
||||
type OAuthOutcomeProvider = (typeof OAUTH_OUTCOME_PROVIDERS)[number];
|
||||
|
||||
const OAUTH_OUTCOME_KEYS: Record<
|
||||
OAuthOutcomeProvider,
|
||||
() => readonly unknown[]
|
||||
> = {
|
||||
polar: queryKeys.polar,
|
||||
oura: queryKeys.oura,
|
||||
whoop: queryKeys.whoop,
|
||||
fitbit: queryKeys.fitbit,
|
||||
};
|
||||
|
||||
/**
|
||||
* Reason tags the four OAuth callbacks emit. Known tags resolve to a specific
|
||||
* message; anything else falls back to the provider's `generic` copy. Union of
|
||||
* the Polar/Oura set (`rate_limited`) and the WHOOP/Fitbit set (`expired`).
|
||||
*/
|
||||
const OAUTH_OUTCOME_REASONS = new Set([
|
||||
"csrf1",
|
||||
"state",
|
||||
"cross_user",
|
||||
"nocode",
|
||||
"nocreds",
|
||||
"token",
|
||||
"rate_limited",
|
||||
"expired",
|
||||
]);
|
||||
|
||||
type OAuthOutcome =
|
||||
| { provider: OAuthOutcomeProvider; kind: "connected" }
|
||||
| { provider: OAuthOutcomeProvider; kind: "error"; reason: string };
|
||||
|
||||
/**
|
||||
* Parse the OAuth-return outcome from a URL query string. Reads the first
|
||||
* provider whose `?<provider>=connected|error` param is present, in
|
||||
* `OAUTH_OUTCOME_PROVIDERS` order. Pure + exported so the four-provider
|
||||
* coverage is unit-testable without a browser.
|
||||
*/
|
||||
export function parseOAuthOutcome(search: string): OAuthOutcome | null {
|
||||
const params = new URLSearchParams(search);
|
||||
for (const provider of OAUTH_OUTCOME_PROVIDERS) {
|
||||
const v = params.get(provider);
|
||||
if (v === "connected") return { provider, kind: "connected" };
|
||||
if (v === "error") {
|
||||
return { provider, kind: "error", reason: params.get("reason") ?? "unknown" };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the i18n key for an error reason tag. Known tags map to the
|
||||
* provider-specific message; anything else falls back to `generic`.
|
||||
*/
|
||||
export function oauthReasonKey(
|
||||
provider: OAuthOutcomeProvider,
|
||||
reason: string,
|
||||
): string {
|
||||
return OAUTH_OUTCOME_REASONS.has(reason)
|
||||
? `settings.${provider}OauthError.${reason}`
|
||||
: `settings.${provider}OauthError.generic`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt the consolidated-envelope view-model into the OAuth card's status
|
||||
* shape. Returns `undefined` when the envelope hasn't loaded so the card
|
||||
* renders its loading/disconnected default rather than a half-populated state.
|
||||
*/
|
||||
function toOAuthStatus(
|
||||
vm: IntegrationStatusViewModel | undefined,
|
||||
): OAuthProviderStatus | undefined {
|
||||
if (!vm) return undefined;
|
||||
return {
|
||||
connected: vm.connected ?? false,
|
||||
configured: vm.configured ?? false,
|
||||
available: vm.available ?? false,
|
||||
hasOwnCredentials: vm.hasOwnCredentials,
|
||||
state: vm.state,
|
||||
lastSuccessAt: vm.lastSuccessAt,
|
||||
lastAttemptAt: vm.lastAttemptAt,
|
||||
lastError: vm.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
export function ConnectionsPanel() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated);
|
||||
|
||||
// OAuth callback handler — reads `?withings=connected|error&reason=…`
|
||||
// from the URL (lazy initialiser, same shape as the Codex handler in
|
||||
// `ai-section.tsx`) and surfaces the outcome as a toast. Pre-fix the
|
||||
// callback set these params and nothing ever read them: a user came
|
||||
// back from Withings onto a silently unchanged settings page.
|
||||
const [withingsOauthOutcome] = useState<WithingsOauthOutcome | null>(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const status = params.get("withings");
|
||||
if (status === "connected") return { kind: "connected" };
|
||||
if (status === "error") {
|
||||
return { kind: "error", reason: params.get("reason") ?? "unknown" };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!withingsOauthOutcome) return;
|
||||
// Scrub the one-shot params so a reload / bookmark doesn't replay
|
||||
// the toast.
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("withings");
|
||||
url.searchParams.delete("reason");
|
||||
router.replace(`${url.pathname}${url.search}`, { scroll: false });
|
||||
if (withingsOauthOutcome.kind === "connected") {
|
||||
toast.success(t("settings.withingsOauthConnected"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
} else {
|
||||
const reasonKey =
|
||||
WITHINGS_OAUTH_ERROR_KEYS[withingsOauthOutcome.reason] ??
|
||||
"settings.withingsOauthError.generic";
|
||||
toast.error(t("settings.withingsOauthFailed"), {
|
||||
description: t(reasonKey),
|
||||
duration: 10_000,
|
||||
});
|
||||
}
|
||||
}, [withingsOauthOutcome, router, queryClient, t]);
|
||||
|
||||
// v1.17.0 (F4) — generic OAuth-callback toast for the OAuth providers.
|
||||
// The Polar / Oura / WHOOP / Fitbit callbacks redirect back with
|
||||
// `?<provider>=connected` or `?<provider>=error&reason=<tag>`; surface the
|
||||
// outcome as a toast and scrub the one-shot params so a reload doesn't replay
|
||||
// it. Pre-v1.17.1 only Polar/Oura were read here — a user returning from a
|
||||
// WHOOP or Fitbit round-trip landed on a silently unchanged settings page,
|
||||
// the same gap the Withings handler was written to close.
|
||||
const [oauthOutcome] = useState<OAuthOutcome | null>(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
return parseOAuthOutcome(window.location.search);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!oauthOutcome) return;
|
||||
const { provider } = oauthOutcome;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete(provider);
|
||||
url.searchParams.delete("reason");
|
||||
router.replace(`${url.pathname}${url.search}`, { scroll: false });
|
||||
if (oauthOutcome.kind === "connected") {
|
||||
toast.success(t(`settings.${provider}OauthConnected`));
|
||||
// Polar/Oura own a per-card status query; WHOOP/Fitbit read off the
|
||||
// consolidated envelope — invalidate both so the card repaints either way.
|
||||
queryClient.invalidateQueries({ queryKey: OAUTH_OUTCOME_KEYS[provider]() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
} else {
|
||||
// Known reason tags resolve to a specific message; anything else falls
|
||||
// back to the generic copy (matching the Withings handler).
|
||||
toast.error(t(`settings.${provider}OauthFailed`), {
|
||||
description: t(oauthReasonKey(provider, oauthOutcome.reason)),
|
||||
duration: 10_000,
|
||||
});
|
||||
}
|
||||
}, [oauthOutcome, router, queryClient, t]);
|
||||
|
||||
const withingsViewModel = pickStatus(integrationStatus, "withings");
|
||||
const whoopViewModel = pickStatus(integrationStatus, "whoop");
|
||||
const fitbitViewModel = pickStatus(integrationStatus, "fitbit");
|
||||
// v1.17.1 — Polar/Oura now read off the same consolidated envelope; the cards
|
||||
// no longer fire their own /api/<provider>/status round-trip.
|
||||
const polarViewModel = toOAuthStatus(pickStatus(integrationStatus, "polar"));
|
||||
const ouraViewModel = toOAuthStatus(pickStatus(integrationStatus, "oura"));
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="connections-panel">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.integrations.connections.description")}
|
||||
</p>
|
||||
|
||||
<WithingsCard viewModel={withingsViewModel} />
|
||||
<WhoopCard viewModel={whoopViewModel} />
|
||||
<FitbitCard viewModel={fitbitViewModel} />
|
||||
<PolarCard enabled={isAuthenticated} viewModel={polarViewModel} />
|
||||
<OuraCard enabled={isAuthenticated} viewModel={ouraViewModel} />
|
||||
<NightscoutCard enabled={isAuthenticated} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<NotificationChannelsPanel>` — the delivery-provider half of Settings →
|
||||
* Integrations.
|
||||
*
|
||||
* v1.18.0 (S3) — the notification CHANNELS (Telegram / ntfy / Web Push /
|
||||
* Webhook / Email, plus the live per-channel status surface) moved out of
|
||||
* Settings → Notifications and into Integrations. Channels are delivery
|
||||
* providers — the same conceptual family as Withings / WHOOP / a connected
|
||||
* device — not notification preferences. The reminder-TYPE content (which
|
||||
* events fire) stays under Settings → Reminders / Notifications.
|
||||
*
|
||||
* Renders inside the Integrations "Channels" sub-tab. Owns no page header of
|
||||
* its own; the parent section supplies it.
|
||||
*/
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { NotificationStatusCard } from "@/components/settings/notification-status-card";
|
||||
import { NtfyCard } from "@/components/settings/ntfy-card";
|
||||
import { TelegramCard } from "@/components/settings/telegram-card";
|
||||
import { WebPushCard } from "@/components/settings/web-push-card";
|
||||
import { WebhookCard } from "@/components/settings/webhook-card";
|
||||
import { EmailCard } from "@/components/settings/email-card";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
interface GlobalServiceAvailability {
|
||||
telegramGlobal: boolean;
|
||||
ntfyGlobal: boolean;
|
||||
webPushGlobal: boolean;
|
||||
apiGlobal: boolean;
|
||||
moodLogGlobal: boolean;
|
||||
}
|
||||
|
||||
export function NotificationChannelsPanel() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: globalServices } = useQuery({
|
||||
queryKey: queryKeys.settingsGlobalServices(),
|
||||
queryFn: async () => {
|
||||
return apiGet<GlobalServiceAvailability>("/api/settings/global-services");
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const showTelegram = globalServices?.telegramGlobal ?? true;
|
||||
const showNtfy = globalServices?.ntfyGlobal ?? true;
|
||||
const showWebPush = globalServices?.webPushGlobal ?? true;
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="notification-channels-panel">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.integrations.channels.description")}
|
||||
</p>
|
||||
|
||||
<NotificationStatusCard />
|
||||
|
||||
{/* v1.4.27 MB3 — `scroll-mt-28` keeps each channel card's anchor
|
||||
target below the safe-area sticky page header when the user lands
|
||||
on `/settings/integrations#telegram` or similar. */}
|
||||
{showTelegram && (
|
||||
<div id="telegram" className="scroll-mt-28">
|
||||
<TelegramCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
)}
|
||||
{showNtfy && (
|
||||
<div id="ntfy" className="scroll-mt-28">
|
||||
<NtfyCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
)}
|
||||
{showWebPush && (
|
||||
<div id="web-push" className="scroll-mt-28">
|
||||
<WebPushCard />
|
||||
</div>
|
||||
)}
|
||||
{/* v1.17.1 — generic outbound webhook (Gotify / Discord / Slack /
|
||||
Matrix / Home Assistant in one channel). */}
|
||||
<div id="webhook" className="scroll-mt-28">
|
||||
<WebhookCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
{/* v1.17.1 — SMTP / email. The card hides itself when the operator
|
||||
hasn't configured SMTP_* env, so it never shows a dead toggle. */}
|
||||
<div id="email" className="scroll-mt-28">
|
||||
<EmailCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { CoachNudgeCard } from "@/components/settings/coach-nudge-card";
|
||||
import { LowStockCard } from "@/components/settings/low-stock-card";
|
||||
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
|
||||
import { NotificationStatusCard } from "@/components/settings/notification-status-card";
|
||||
import { NtfyCard } from "@/components/settings/ntfy-card";
|
||||
import { TelegramCard } from "@/components/settings/telegram-card";
|
||||
import { WebPushCard } from "@/components/settings/web-push-card";
|
||||
import { WebhookCard } from "@/components/settings/webhook-card";
|
||||
import { EmailCard } from "@/components/settings/email-card";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
interface GlobalServiceAvailability {
|
||||
telegramGlobal: boolean;
|
||||
ntfyGlobal: boolean;
|
||||
webPushGlobal: boolean;
|
||||
apiGlobal: boolean;
|
||||
moodLogGlobal: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* `<NotificationsSection>` — Settings → Notifications.
|
||||
*
|
||||
* v1.18.0 (S3) — the delivery CHANNELS (Telegram / ntfy / Web Push / Webhook /
|
||||
* Email + the live per-channel status surface) moved to Settings →
|
||||
* Integrations → Channels: a channel is a delivery provider, the same family as
|
||||
* a connected device, not a notification preference. What stays here is the
|
||||
* reminder-TYPE content (which events fire): mood reminder, medication
|
||||
* low-stock runway, proactive Coach nudge. S4 will consolidate these onto the
|
||||
* Reminders hub.
|
||||
*/
|
||||
export function NotificationsSection() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data: globalServices } = useQuery({
|
||||
queryKey: queryKeys.settingsGlobalServices(),
|
||||
queryFn: async () => {
|
||||
return apiGet<GlobalServiceAvailability>("/api/settings/global-services");
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const showTelegram = globalServices?.telegramGlobal ?? true;
|
||||
const showNtfy = globalServices?.ntfyGlobal ?? true;
|
||||
const showWebPush = globalServices?.webPushGlobal ?? true;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-notifications-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* v1.4.33 IW7 — disambiguate the channel-config screen from the
|
||||
inbox at `/notifications`. Both surfaces used to be named plain
|
||||
"Notifications", so the crumb spells out where the user is and
|
||||
offers a one-tap jump to the inbox. */}
|
||||
{/* v1.4.33 IW7 — disambiguate this settings screen from the inbox at
|
||||
`/notifications`. The crumb spells out where the user is and offers a
|
||||
one-tap jump to the inbox. */}
|
||||
<nav
|
||||
aria-label={t("nav.breadcrumb")}
|
||||
className="text-muted-foreground text-xs"
|
||||
@@ -83,40 +64,21 @@ export function NotificationsSection() {
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.notifications.description")}
|
||||
</p>
|
||||
{/* v1.18.0 (S3) — the delivery channels now live under Integrations.
|
||||
Cross-link so a user looking for "where do I connect Telegram"
|
||||
finds them in one tap. */}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.notifications.channelsHint")}{" "}
|
||||
<Link
|
||||
href="/settings/integrations"
|
||||
className="text-primary underline underline-offset-2"
|
||||
data-slot="notifications-channels-cross-link"
|
||||
>
|
||||
{t("settings.sections.notifications.channelsHintLink")}
|
||||
</Link>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<NotificationStatusCard />
|
||||
|
||||
{/* v1.4.27 MB3 — `scroll-mt-28` keeps each notification card's
|
||||
anchor target below the safe-area sticky page header when the
|
||||
user lands on `/settings/notifications#telegram` or similar.
|
||||
Without the offset the section title pins under the header on
|
||||
mobile and the user has to scroll back up to find it. */}
|
||||
{showTelegram && (
|
||||
<div id="telegram" className="scroll-mt-28">
|
||||
<TelegramCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
)}
|
||||
{showNtfy && (
|
||||
<div id="ntfy" className="scroll-mt-28">
|
||||
<NtfyCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
)}
|
||||
{showWebPush && (
|
||||
<div id="web-push" className="scroll-mt-28">
|
||||
<WebPushCard />
|
||||
</div>
|
||||
)}
|
||||
{/* v1.17.1 — generic outbound webhook (Gotify / Discord / Slack /
|
||||
Matrix / Home Assistant in one channel). */}
|
||||
<div id="webhook" className="scroll-mt-28">
|
||||
<WebhookCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
{/* v1.17.1 — SMTP / email. The card hides itself when the operator
|
||||
hasn't configured SMTP_* env, so it never shows a dead toggle. */}
|
||||
<div id="email" className="scroll-mt-28">
|
||||
<EmailCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
<div id="mood-reminder" className="scroll-mt-28">
|
||||
<MoodReminderCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user