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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 22:48:05 +02:00
1 parent 5d55347dd0
commit 4a1971c0dd
9 files changed
+155

No files matched your search

+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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ń",
+2
View File
@@ -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")}
>
<AssistantSection />
<ModuleAvailabilitySection />
<AiServerKeySection />
</SectionFrame>
);
@@ -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<ModuleKey, boolean>;
interface ModuleAvailabilityResponse {
availability: AvailabilityMap;
}
function useModuleAvailability() {
return useQuery({
queryKey: queryKeys.adminModuleAvailability(),
queryFn: async () => {
return apiGet<ModuleAvailabilityResponse>(
"/api/admin/settings/module-availability",
);
},
});
}
function useUpdateModuleAvailability() {
const client = useQueryClient();
const { t } = useTranslations();
return useMutation({
mutationFn: async (
patch: Partial<AvailabilityMap>,
): Promise<ModuleAvailabilityResponse> => {
return apiPatch<ModuleAvailabilityResponse>(
"/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 (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={ToggleLeft}
title={t("admin.modules.title")}
description={t("admin.modules.description")}
/>
<div className="mt-4 space-y-4 pl-7">
{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 (
<SettingsToggle
key={key}
label={t(def.labelKey)}
description={t(def.descriptionKey)}
checked={available}
onCheckedChange={(checked) =>
mutation.mutate({ [key]: checked } as Partial<AvailabilityMap>)
}
disabled={mutation.isPending}
/>
);
})}
</div>
</div>
);
}
+3
View File
@@ -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,