diff --git a/messages/de.json b/messages/de.json
index 4b71b036d..8bd10c0ba 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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",
diff --git a/messages/en.json b/messages/en.json
index 2020367c1..d63cc8d01 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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",
diff --git a/messages/es.json b/messages/es.json
index 84a8ec7fa..58fb8e67b 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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",
diff --git a/messages/fr.json b/messages/fr.json
index 41658b9ab..90f37fe30 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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",
diff --git a/messages/it.json b/messages/it.json
index 79a1f8b2e..78fbc6e9e 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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",
diff --git a/messages/pl.json b/messages/pl.json
index 1fabc2b51..c85a7106f 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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",
diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx
index 7484da40f..dfebc9b88 100644
--- a/src/components/settings/integrations-section.tsx
+++ b/src/components/settings/integrations-section.tsx
@@ -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";
+/**
+ * `` — 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=`.
- * 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 = {
- 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
- * `?=connected|error&reason=` 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 `?=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(() => {
- 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
- // `?=connected` or `?=error&reason=`; 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(() => {
- 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//status round-trip.
- const polarViewModel = toOAuthStatus(pickStatus(integrationStatus, "polar"));
- const ouraViewModel = toOAuthStatus(pickStatus(integrationStatus, "oura"));
+ const [tab, setTab] = useState("connections");
return (
{t("settings.sections.integrations.description")}
- {/* 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. */}
-
+
+
+
+ {/* 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 && (
+
+
+
+ )}
+ {showNtfy && (
+
+
+
+ )}
+ {showWebPush && (
+
+
+
+ )}
+ {/* v1.17.1 — generic outbound webhook (Gotify / Discord / Slack /
+ Matrix / Home Assistant in one channel). */}
+
+
+
+ {/* v1.17.1 — SMTP / email. The card hides itself when the operator
+ hasn't configured SMTP_* env, so it never shows a dead toggle. */}
+
+
+
+
+ );
+}
diff --git a/src/components/settings/notifications-section.tsx b/src/components/settings/notifications-section.tsx
index 600569109..35add96c8 100644
--- a/src/components/settings/notifications-section.tsx
+++ b/src/components/settings/notifications-section.tsx
@@ -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;
-}
+/**
+ * `` — 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("/api/settings/global-services");
- },
- enabled: isAuthenticated,
- });
-
- const showTelegram = globalServices?.telegramGlobal ?? true;
- const showNtfy = globalServices?.ntfyGlobal ?? true;
- const showWebPush = globalServices?.webPushGlobal ?? true;
-
return (
- {/* 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. */}