From 4a1971c0ddcca2eb7c41be47f04c066ef53b7b21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 22:48:05 +0200 Subject: [PATCH] feat(admin): module availability matrix Add an operator-side toggle matrix to the admin assistant section that flips each toggleable module on or off server-wide via PATCH /api/admin/settings/module-availability. Mirrors the assistant-flags matrix: one neutral toggle per module, optimistic flip with a revert on failure, and an authMe invalidation so the resolved module map re-gates the session immediately. New admin.modules.* labels across all six locales; core domains never appear. --- messages/de.json | 6 + messages/en.json | 6 + messages/es.json | 6 + messages/fr.json | 6 + messages/it.json | 6 + messages/pl.json | 6 + src/app/admin/[section]/renderer.tsx | 2 + .../admin/module-availability-section.tsx | 114 ++++++++++++++++++ src/lib/query-keys/admin.ts | 3 + 9 files changed, 155 insertions(+) create mode 100644 src/components/admin/module-availability-section.tsx diff --git a/messages/de.json b/messages/de.json index 9a9480e87..4b71b036d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5013,6 +5013,12 @@ "description": "Das ?-Symbol neben der Health-Score-Differenz. Die Zahl bleibt unabhängig davon sichtbar." } }, + "modules": { + "title": "Modulverfügbarkeit", + "description": "Schalte ein Modul für den gesamten Server ab. Ein hier deaktiviertes Modul ist für jedes Konto aus – unabhängig von den persönlichen Einstellungen. Kerndaten (Gewicht, Blutdruck, Puls, Medikamente) sind immer aktiv und lassen sich nicht abschalten.", + "disabledHint": "Serverweit deaktiviert", + "saveError": "Modulverfügbarkeit konnte nicht aktualisiert werden." + }, "bugReportRepo": "Repository", "bugReportRepoPlaceholder": "owner/repo", "bugReportToken": "Issue-Token", diff --git a/messages/en.json b/messages/en.json index 9c94551de..2020367c1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5013,6 +5013,12 @@ "description": "The ? glyph next to the Health-Score delta line. The delta digit stays visible regardless." } }, + "modules": { + "title": "Module availability", + "description": "Turn a module off for the whole server. A module disabled here is off for every account regardless of personal preference. Core domains (weight, blood pressure, pulse, medications) are always on and cannot be disabled.", + "disabledHint": "Disabled server-wide", + "saveError": "Could not update module availability." + }, "bugReportRepo": "Repository", "bugReportRepoPlaceholder": "owner/repo", "bugReportToken": "Issue token", diff --git a/messages/es.json b/messages/es.json index 8af901931..84a8ec7fa 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5013,6 +5013,12 @@ "description": "El icono ? junto a la línea de delta del Health-Score. El dígito del delta permanece visible siempre." } }, + "modules": { + "title": "Disponibilidad de módulos", + "description": "Desactiva un módulo para todo el servidor. Un módulo desactivado aquí queda apagado para todas las cuentas, sin importar la preferencia personal. Los dominios principales (peso, presión arterial, pulso, medicación) están siempre activos y no se pueden desactivar.", + "disabledHint": "Desactivado en todo el servidor", + "saveError": "No se pudo actualizar la disponibilidad de módulos." + }, "bugReportRepo": "Repositorio", "bugReportRepoPlaceholder": "owner/repo", "bugReportToken": "Token de issues", diff --git a/messages/fr.json b/messages/fr.json index 84d6d1c1e..41658b9ab 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5013,6 +5013,12 @@ "description": "L'icône ? près de la ligne de delta du Health-Score. Le chiffre du delta reste visible quoi qu'il arrive." } }, + "modules": { + "title": "Disponibilité des modules", + "description": "Désactivez un module pour tout le serveur. Un module désactivé ici est coupé pour chaque compte, quelle que soit la préférence personnelle. Les domaines essentiels (poids, tension, pouls, médicaments) restent toujours actifs et ne peuvent pas être désactivés.", + "disabledHint": "Désactivé sur tout le serveur", + "saveError": "Impossible de mettre à jour la disponibilité des modules." + }, "bugReportRepo": "Dépôt", "bugReportRepoPlaceholder": "owner/repo", "bugReportToken": "Jeton d'issue", diff --git a/messages/it.json b/messages/it.json index 91514bbbb..79a1f8b2e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5013,6 +5013,12 @@ "description": "L'icona ? accanto alla riga del delta dell'Health-Score. La cifra del delta resta visibile in ogni caso." } }, + "modules": { + "title": "Disponibilità dei moduli", + "description": "Disattiva un modulo per l'intero server. Un modulo disattivato qui è spento per ogni account, indipendentemente dalle preferenze personali. I domini principali (peso, pressione, polso, farmaci) sono sempre attivi e non possono essere disattivati.", + "disabledHint": "Disattivato a livello di server", + "saveError": "Impossibile aggiornare la disponibilità dei moduli." + }, "bugReportRepo": "Repository", "bugReportRepoPlaceholder": "owner/repo", "bugReportToken": "Token delle issue", diff --git a/messages/pl.json b/messages/pl.json index 68cfd262f..1fabc2b51 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5013,6 +5013,12 @@ "description": "Ikona ? obok linii delty Health-Score. Cyfra delty pozostaje widoczna niezależnie." } }, + "modules": { + "title": "Dostępność modułów", + "description": "Wyłącz moduł dla całego serwera. Moduł wyłączony tutaj jest nieaktywny dla każdego konta, niezależnie od preferencji użytkownika. Domeny podstawowe (waga, ciśnienie, tętno, leki) są zawsze włączone i nie można ich wyłączyć.", + "disabledHint": "Wyłączone na całym serwerze", + "saveError": "Nie udało się zaktualizować dostępności modułów." + }, "bugReportRepo": "Repozytorium", "bugReportRepoPlaceholder": "owner/repo", "bugReportToken": "Token zgłoszeń", diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index 4b49212d2..d1e4db387 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -29,6 +29,7 @@ import { FeedbackInboxSection } from "@/components/admin/feedback-inbox-section" import { GeneralSettingsSection } from "@/components/admin/general-settings-section"; import { IntegrationsGroupSection } from "@/components/admin/integrations-group-section"; import { LoginOverviewSection } from "@/components/admin/login-overview-section"; +import { ModuleAvailabilitySection } from "@/components/admin/module-availability-section"; import { RemindersSection } from "@/components/admin/reminders-section"; import { ServicesSection } from "@/components/admin/services-section"; import { SystemStatusSection } from "@/components/admin/system-status-section"; @@ -105,6 +106,7 @@ export function AdminSectionRenderer({ subtitle={t("admin.section.assistant.subtitle")} > + ); diff --git a/src/components/admin/module-availability-section.tsx b/src/components/admin/module-availability-section.tsx new file mode 100644 index 000000000..c15c72723 --- /dev/null +++ b/src/components/admin/module-availability-section.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { ToggleLeft } from "lucide-react"; +import { toast } from "sonner"; + +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { MODULE_KEYS, MODULE_REGISTRY } from "@/lib/modules/registry"; +import type { ModuleKey } from "@/lib/modules/registry"; +import { SettingsToggle } from "./_shared"; +import { apiGet, apiPatch } from "@/lib/api/api-fetch"; + +/** + * v1.18.0 — operator-side panel for server-wide module availability, the + * SECOND layer of the two-layer module model. A module turned off here is + * off for EVERY account regardless of personal preference — mirroring how + * the assistant master flag sits above the per-user coach opt-out. + * + * Core domains (weight, blood pressure, pulse, medications) are NOT module + * keys and never appear here; the gate keeps them always-on structurally. + * + * UX mirrors the assistant-flags matrix: one toggle per module, calm and + * neutral, optimistic flip + server confirm via `useMutation`, with a + * failure toast that reverts by re-invalidating the read query. + */ + +type AvailabilityMap = Record; + +interface ModuleAvailabilityResponse { + availability: AvailabilityMap; +} + +function useModuleAvailability() { + return useQuery({ + queryKey: queryKeys.adminModuleAvailability(), + queryFn: async () => { + return apiGet( + "/api/admin/settings/module-availability", + ); + }, + }); +} + +function useUpdateModuleAvailability() { + const client = useQueryClient(); + const { t } = useTranslations(); + return useMutation({ + mutationFn: async ( + patch: Partial, + ): Promise => { + return apiPatch( + "/api/admin/settings/module-availability", + patch, + ); + }, + onSuccess: (data) => { + client.setQueryData(queryKeys.adminModuleAvailability(), data); + // The resolved `/api/auth/me` module map depends on this operator + // layer; bust it so the operator sees the change within the session. + client.invalidateQueries({ queryKey: queryKeys.authMe() }); + toast.success(t("common.saved")); + }, + onError: (err) => { + toast.error( + err instanceof Error && err.message + ? err.message + : t("admin.modules.saveError"), + ); + client.invalidateQueries({ + queryKey: queryKeys.adminModuleAvailability(), + }); + }, + }); +} + +export function ModuleAvailabilitySection() { + const { t } = useTranslations(); + const { data } = useModuleAvailability(); + const mutation = useUpdateModuleAvailability(); + + const availability = data?.availability; + + return ( +
+ +
+ {MODULE_KEYS.map((key) => { + const def = MODULE_REGISTRY[key]; + // Default-available until the read resolves; an explicit `false` + // from the operator layer is the only thing that turns it off. + const available = availability?.[key] ?? true; + return ( + + mutation.mutate({ [key]: checked } as Partial) + } + disabled={mutation.isPending} + /> + ); + })} +
+
+ ); +} diff --git a/src/lib/query-keys/admin.ts b/src/lib/query-keys/admin.ts index 5f604108e..d7472917e 100644 --- a/src/lib/query-keys/admin.ts +++ b/src/lib/query-keys/admin.ts @@ -23,6 +23,9 @@ export const adminKeys = { range: string | undefined, ) => ["admin", "app-logs", traceId, action, level, range] as const, adminAssistantFlags: () => ["admin", "settings", "assistant-flags"] as const, + /** v1.18.0 — operator-level server-wide module availability matrix. */ + adminModuleAvailability: () => + ["admin", "settings", "module-availability"] as const, adminBackups: () => ["admin", "backups"] as const, adminCoachFeedback: () => ["admin", "coach-feedback"] as const, adminFeedback: (status: string) => ["admin", "feedback", status] as const,