mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
feat(settings): add an MCP connector card with health:read tokens
A dedicated Settings card co-locates the off-by-default MCP module toggle with token management for connecting an external assistant: - Mint / list / revoke a dedicated `health:read`-scoped token (shown once, copy to clipboard) via new `/api/mcp/tokens` routes — never the `medication:ingest` scope, never the wildcard. - Surface the connector URL and the enable switch in one place, wired to the existing module-preference endpoint. - New section wired into the Settings shell next to API & Tokens, with EN + DE strings (and the other locales) under `settings.mcp` and `settings.sections.mcp`.
This commit is contained in:
1 parent
63e4b17843
commit
06363771ee
15 files changed
+632
-6
No files matched your search
+17
-1
@@ -4162,6 +4162,10 @@
|
||||
"title": "Kanäle",
|
||||
"description": "Zustellung über Telegram, ntfy, Web-Push, Webhook und E-Mail.",
|
||||
"subtitle": "Wohin Erinnerungen und Hinweise zugestellt werden."
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP-Connector",
|
||||
"subtitle": "Verbinde einen externen Assistenten mit deinen eigenen Gesundheitsdaten."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -4892,7 +4896,19 @@
|
||||
"polarDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
|
||||
"ouraDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
|
||||
"nightscoutDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
|
||||
"integrationCredentialsHint": "Wo du diese Zugangsdaten findest und wie du deine eigene App registrierst, steht in der Einrichtungsanleitung oben."
|
||||
"integrationCredentialsHint": "Wo du diese Zugangsdaten findest und wie du deine eigene App registrierst, steht in der Einrichtungsanleitung oben.",
|
||||
"mcp": {
|
||||
"enableTitle": "Remote-MCP-Endpunkt",
|
||||
"enableDescription": "Erlaube einem MCP-fähigen Assistenten (Claude.ai, ChatGPT), deine Gesundheitsdaten über ein begrenztes Token zu lesen. Standardmäßig aus.",
|
||||
"enabled": "Aktiv",
|
||||
"disabled": "Inaktiv",
|
||||
"enableToggleLabel": "MCP-Endpunkt aktivieren",
|
||||
"endpointLabel": "Connector-URL",
|
||||
"connectDescription": "Füge diese URL in die Einrichtung des eigenen Connectors deines Assistenten ein und autorisiere dich mit dem Token unten oder melde dich bei Aufforderung an.",
|
||||
"tokensTitle": "Connector-Tokens",
|
||||
"tokensDescription": "Erzeuge ein schreibgeschütztes Token, um einen Assistenten zu verbinden. Das Token wird nur einmal angezeigt.",
|
||||
"scopeNote": "Tokens sind auf health:read beschränkt — nur Lesezugriff auf deine eigenen Daten. Sie können niemals Daten schreiben oder Admin-Funktionen erreichen."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
|
||||
+17
-1
@@ -4162,6 +4162,10 @@
|
||||
"title": "Channels",
|
||||
"description": "Telegram, ntfy, Web Push, Webhook, and Email delivery.",
|
||||
"subtitle": "Where reminders and alerts are delivered."
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP Connector",
|
||||
"subtitle": "Connect an external assistant to your own health data."
|
||||
}
|
||||
},
|
||||
"profile": "Profile",
|
||||
@@ -4892,7 +4896,19 @@
|
||||
"polarDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
|
||||
"ouraDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
|
||||
"nightscoutDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
|
||||
"integrationCredentialsHint": "Where to find these credentials and how to register your own app is covered in the setup guide above."
|
||||
"integrationCredentialsHint": "Where to find these credentials and how to register your own app is covered in the setup guide above.",
|
||||
"mcp": {
|
||||
"enableTitle": "Remote MCP endpoint",
|
||||
"enableDescription": "Let an MCP-capable assistant (Claude.ai, ChatGPT) read your health data over a scoped token. Off by default.",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"enableToggleLabel": "Enable the MCP endpoint",
|
||||
"endpointLabel": "Connector URL",
|
||||
"connectDescription": "Paste this URL into your assistant's custom-connector setup, then authorize with the token below or sign in when prompted.",
|
||||
"tokensTitle": "Connector tokens",
|
||||
"tokensDescription": "Mint a read-only token to connect an assistant. The token is shown once.",
|
||||
"scopeNote": "Tokens are scoped to health:read — read-only access to your own records. They can never write data or reach admin functions."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
|
||||
+17
-1
@@ -4162,6 +4162,10 @@
|
||||
"title": "Canales",
|
||||
"description": "Envío por Telegram, ntfy, Web Push, Webhook y correo electrónico.",
|
||||
"subtitle": "Dónde se entregan los recordatorios y avisos."
|
||||
},
|
||||
"mcp": {
|
||||
"title": "Conector MCP",
|
||||
"subtitle": "Conecta un asistente externo a tus propios datos de salud."
|
||||
}
|
||||
},
|
||||
"profile": "Perfil",
|
||||
@@ -4892,7 +4896,19 @@
|
||||
"polarDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
|
||||
"ouraDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
|
||||
"nightscoutDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
|
||||
"integrationCredentialsHint": "Dónde encontrar estas credenciales y cómo registrar tu propia aplicación se explica en la guía de configuración de arriba."
|
||||
"integrationCredentialsHint": "Dónde encontrar estas credenciales y cómo registrar tu propia aplicación se explica en la guía de configuración de arriba.",
|
||||
"mcp": {
|
||||
"enableTitle": "Endpoint MCP remoto",
|
||||
"enableDescription": "Permite que un asistente compatible con MCP (Claude.ai, ChatGPT) lea tus datos de salud mediante un token limitado. Desactivado por defecto.",
|
||||
"enabled": "Activado",
|
||||
"disabled": "Desactivado",
|
||||
"enableToggleLabel": "Activar el endpoint MCP",
|
||||
"endpointLabel": "URL del conector",
|
||||
"connectDescription": "Pega esta URL en la configuración del conector personalizado de tu asistente y autoriza con el token de abajo o inicia sesión cuando se te solicite.",
|
||||
"tokensTitle": "Tokens del conector",
|
||||
"tokensDescription": "Genera un token de solo lectura para conectar un asistente. El token se muestra una sola vez.",
|
||||
"scopeNote": "Los tokens se limitan a health:read: acceso de solo lectura a tus propios registros. Nunca pueden escribir datos ni acceder a funciones de administración."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
|
||||
+17
-1
@@ -4162,6 +4162,10 @@
|
||||
"title": "Canaux",
|
||||
"description": "Distribution via Telegram, ntfy, Web Push, Webhook et e-mail.",
|
||||
"subtitle": "Où sont envoyés les rappels et alertes."
|
||||
},
|
||||
"mcp": {
|
||||
"title": "Connecteur MCP",
|
||||
"subtitle": "Connectez un assistant externe à vos propres données de santé."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -4892,7 +4896,19 @@
|
||||
"polarDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
|
||||
"ouraDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
|
||||
"nightscoutDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
|
||||
"integrationCredentialsHint": "Où trouver ces identifiants et comment enregistrer ta propre application est expliqué dans le guide de configuration ci-dessus."
|
||||
"integrationCredentialsHint": "Où trouver ces identifiants et comment enregistrer ta propre application est expliqué dans le guide de configuration ci-dessus.",
|
||||
"mcp": {
|
||||
"enableTitle": "Point de terminaison MCP distant",
|
||||
"enableDescription": "Autorisez un assistant compatible MCP (Claude.ai, ChatGPT) à lire vos données de santé via un jeton restreint. Désactivé par défaut.",
|
||||
"enabled": "Activé",
|
||||
"disabled": "Désactivé",
|
||||
"enableToggleLabel": "Activer le point de terminaison MCP",
|
||||
"endpointLabel": "URL du connecteur",
|
||||
"connectDescription": "Collez cette URL dans la configuration du connecteur personnalisé de votre assistant, puis autorisez avec le jeton ci-dessous ou connectez-vous lorsque vous y êtes invité.",
|
||||
"tokensTitle": "Jetons du connecteur",
|
||||
"tokensDescription": "Générez un jeton en lecture seule pour connecter un assistant. Le jeton n'est affiché qu'une seule fois.",
|
||||
"scopeNote": "Les jetons sont limités à health:read : accès en lecture seule à vos propres données. Ils ne peuvent jamais écrire de données ni atteindre les fonctions d'administration."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
|
||||
+17
-1
@@ -4162,6 +4162,10 @@
|
||||
"title": "Canali",
|
||||
"description": "Recapito tramite Telegram, ntfy, Web Push, Webhook ed e-mail.",
|
||||
"subtitle": "Dove vengono recapitati promemoria e avvisi."
|
||||
},
|
||||
"mcp": {
|
||||
"title": "Connettore MCP",
|
||||
"subtitle": "Collega un assistente esterno ai tuoi dati sulla salute."
|
||||
}
|
||||
},
|
||||
"profile": "Profilo",
|
||||
@@ -4892,7 +4896,19 @@
|
||||
"polarDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
|
||||
"ouraDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
|
||||
"nightscoutDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
|
||||
"integrationCredentialsHint": "Dove trovare queste credenziali e come registrare la tua app è spiegato nella guida alla configurazione qui sopra."
|
||||
"integrationCredentialsHint": "Dove trovare queste credenziali e come registrare la tua app è spiegato nella guida alla configurazione qui sopra.",
|
||||
"mcp": {
|
||||
"enableTitle": "Endpoint MCP remoto",
|
||||
"enableDescription": "Consenti a un assistente compatibile con MCP (Claude.ai, ChatGPT) di leggere i tuoi dati sulla salute tramite un token limitato. Disattivato per impostazione predefinita.",
|
||||
"enabled": "Attivo",
|
||||
"disabled": "Inattivo",
|
||||
"enableToggleLabel": "Attiva l'endpoint MCP",
|
||||
"endpointLabel": "URL del connettore",
|
||||
"connectDescription": "Incolla questo URL nella configurazione del connettore personalizzato del tuo assistente e autorizza con il token qui sotto o accedi quando richiesto.",
|
||||
"tokensTitle": "Token del connettore",
|
||||
"tokensDescription": "Genera un token in sola lettura per collegare un assistente. Il token viene mostrato una sola volta.",
|
||||
"scopeNote": "I token sono limitati a health:read: accesso in sola lettura ai tuoi dati. Non possono mai scrivere dati né raggiungere funzioni di amministrazione."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
|
||||
+17
-1
@@ -4162,6 +4162,10 @@
|
||||
"title": "Kanały",
|
||||
"description": "Dostarczanie przez Telegram, ntfy, Web Push, Webhook i e-mail.",
|
||||
"subtitle": "Gdzie dostarczane są przypomnienia i powiadomienia."
|
||||
},
|
||||
"mcp": {
|
||||
"title": "Łącznik MCP",
|
||||
"subtitle": "Połącz zewnętrznego asystenta z własnymi danymi zdrowotnymi."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -4892,7 +4896,19 @@
|
||||
"polarDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
|
||||
"ouraDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
|
||||
"nightscoutDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
|
||||
"integrationCredentialsHint": "Gdzie znaleźć te dane logowania i jak zarejestrować własną aplikację, opisano w przewodniku konfiguracji powyżej."
|
||||
"integrationCredentialsHint": "Gdzie znaleźć te dane logowania i jak zarejestrować własną aplikację, opisano w przewodniku konfiguracji powyżej.",
|
||||
"mcp": {
|
||||
"enableTitle": "Zdalny punkt końcowy MCP",
|
||||
"enableDescription": "Zezwól asystentowi obsługującemu MCP (Claude.ai, ChatGPT) na odczyt danych zdrowotnych za pomocą ograniczonego tokenu. Domyślnie wyłączone.",
|
||||
"enabled": "Włączony",
|
||||
"disabled": "Wyłączony",
|
||||
"enableToggleLabel": "Włącz punkt końcowy MCP",
|
||||
"endpointLabel": "Adres URL łącznika",
|
||||
"connectDescription": "Wklej ten adres URL w konfiguracji własnego łącznika asystenta, a następnie autoryzuj za pomocą poniższego tokenu lub zaloguj się po wyświetleniu monitu.",
|
||||
"tokensTitle": "Tokeny łącznika",
|
||||
"tokensDescription": "Wygeneruj token tylko do odczytu, aby połączyć asystenta. Token jest wyświetlany tylko raz.",
|
||||
"scopeNote": "Tokeny są ograniczone do health:read — dostęp tylko do odczytu własnych danych. Nigdy nie mogą zapisywać danych ani uzyskać dostępu do funkcji administracyjnych."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administracja",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Revoke an MCP connector token. Only the owning user can revoke; the scope
|
||||
* filter keeps this endpoint to `health:read` tokens so it can never revoke a
|
||||
* differently-scoped credential by id alone.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { SCOPE_HEALTH_READ } from "@/lib/mcp/oauth/config";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (_request: Request, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "mcp.tokens.revoke" } });
|
||||
|
||||
if (!(await isApiGloballyEnabled())) {
|
||||
return apiError("API is globally disabled", 403);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const token = await prisma.apiToken.findUnique({ where: { id } });
|
||||
|
||||
if (
|
||||
!token ||
|
||||
token.userId !== user.id ||
|
||||
!token.permissions.includes(SCOPE_HEALTH_READ)
|
||||
) {
|
||||
return apiError("Token not found", 404);
|
||||
}
|
||||
|
||||
await prisma.apiToken.update({ where: { id }, data: { revoked: true } });
|
||||
await auditLog("mcp.tokens.revoke", {
|
||||
userId: user.id,
|
||||
details: { tokenId: id },
|
||||
});
|
||||
|
||||
return apiSuccess({ revoked: true });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* MCP connector tokens — list + mint.
|
||||
*
|
||||
* The MCP settings card mints a DEDICATED `health:read`-scoped Bearer here (NOT
|
||||
* the `medication:ingest` scope the generic `/api/tokens` mints, and NEVER the
|
||||
* `["*"]` wildcard). The raw `hlk_` value is shown once. This is the manual /
|
||||
* stdio / power-user path; the OAuth bridge (`/api/mcp/oauth/*`) mints the same
|
||||
* `health:read` scope automatically for cloud connectors. Both surface here so
|
||||
* the user can see and revoke every credential that can read over MCP.
|
||||
*
|
||||
* `userId` is always narrowed from the session — never a body field. Scope is
|
||||
* hardcoded `["health:read"]`, so this endpoint can never be coerced into
|
||||
* minting a broader grant (no mass assignment).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { issueApiToken } from "@/lib/auth/issue-token";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { SCOPE_HEALTH_READ } from "@/lib/mcp/oauth/config";
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1, "Name required").max(100),
|
||||
expiresInDays: z.number().int().min(1).max(365).optional(),
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "mcp.tokens.list" } });
|
||||
|
||||
if (!(await isApiGloballyEnabled())) {
|
||||
return apiError("API is globally disabled", 403);
|
||||
}
|
||||
|
||||
const tokens = await prisma.apiToken.findMany({
|
||||
where: { userId: user.id, permissions: { has: SCOPE_HEALTH_READ } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
permissions: true,
|
||||
lastUsedAt: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
revoked: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return apiSuccess(tokens);
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "mcp.tokens.create" } });
|
||||
|
||||
if (!(await isApiGloballyEnabled())) {
|
||||
return apiError("API is globally disabled", 403);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = createSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const issued = await issueApiToken({
|
||||
userId: user.id,
|
||||
name: parsed.data.name,
|
||||
// Dedicated, least-privilege MCP read scope — never wildcard, never write.
|
||||
permissions: [SCOPE_HEALTH_READ],
|
||||
expiresInDays: parsed.data.expiresInDays ?? 90,
|
||||
});
|
||||
|
||||
await auditLog("mcp.tokens.create", {
|
||||
userId: user.id,
|
||||
details: { tokenId: issued.tokenId, scope: SCOPE_HEALTH_READ },
|
||||
});
|
||||
|
||||
// Return the raw token ONCE — it can never be retrieved again.
|
||||
return apiSuccess(
|
||||
{ token: issued.token, name: issued.name, expiresAt: issued.expiresAt },
|
||||
201,
|
||||
);
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import { IntegrationsSection } from "@/components/settings/integrations-section"
|
||||
import { LabsSection } from "@/components/settings/labs-section";
|
||||
import { LayoutSection } from "@/components/settings/layout-section";
|
||||
import { MedicationsSection } from "@/components/settings/medications-section";
|
||||
import { McpSection } from "@/components/settings/mcp-section";
|
||||
import { ModulesSection } from "@/components/settings/modules-section";
|
||||
import { MoodSection } from "@/components/settings/mood-section";
|
||||
import { NotificationsSection } from "@/components/settings/notifications-section";
|
||||
@@ -73,6 +74,7 @@ const SECTION_COMPONENTS: Record<
|
||||
vorsorge: VorsorgeSection,
|
||||
thresholds: ThresholdsSection,
|
||||
api: ApiSection,
|
||||
mcp: McpSection,
|
||||
gesundheitsakte: GesundheitsakteSection,
|
||||
sharing: SharingSection,
|
||||
export: ExportSection,
|
||||
|
||||
@@ -116,6 +116,7 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
"ai",
|
||||
"coach",
|
||||
"api",
|
||||
"mcp",
|
||||
"gesundheitsakte",
|
||||
"sharing",
|
||||
"export",
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<McpSection>` — Settings → MCP connector. v1.22.0.
|
||||
*
|
||||
* One card co-locating the two things a self-hoster needs to connect an external
|
||||
* assistant (Claude.ai / ChatGPT) to their own data over the remote MCP
|
||||
* endpoint:
|
||||
*
|
||||
* 1. the off-by-default `mcp` module toggle (the opt-in switch that turns the
|
||||
* `/mcp` surface on for this account — it answers 404 until then), and
|
||||
* 2. a dedicated `health:read`-scoped token (mint shown once, copy, list with
|
||||
* last-used, revoke). This is the manual / stdio path; cloud connectors get
|
||||
* the same scope automatically through the OAuth flow.
|
||||
*
|
||||
* The minted token is NEVER `medication:ingest` and NEVER `["*"]` — it is the
|
||||
* least-privilege read scope and nothing more.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Copy, Key, Loader2, Plug, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet, apiPatch } from "@/lib/api/api-fetch";
|
||||
|
||||
interface McpTokenInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
lastUsedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
revoked: boolean;
|
||||
}
|
||||
|
||||
export function McpSection() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<McpEnableCard />
|
||||
<McpTokensCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function McpEnableCard() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// The opt-in module is OFF until explicitly enabled — `user.modules.mcp` is
|
||||
// the resolved state (true only when the user turned it on).
|
||||
const enabled = user?.modules?.mcp === true;
|
||||
const endpoint =
|
||||
typeof window !== "undefined" ? `${window.location.origin}/mcp` : "/mcp";
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationKey: queryKeys.modulesPrefs(),
|
||||
mutationFn: async (next: boolean) =>
|
||||
apiPatch("/api/auth/me/modules", { mcp: next }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
toast.success(t("settings.sections.modules.saved"));
|
||||
},
|
||||
onError: () => toast.error(t("settings.sections.modules.error")),
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Plug}
|
||||
title={t("settings.mcp.enableTitle")}
|
||||
description={t("settings.mcp.enableDescription")}
|
||||
status={
|
||||
enabled ? (
|
||||
<Badge className="bg-success/15 text-success">
|
||||
{t("settings.mcp.enabled")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">{t("settings.mcp.disabled")}</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-medium">
|
||||
{t("settings.mcp.enableToggleLabel")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={toggle.isPending}
|
||||
onCheckedChange={(next) => toggle.mutate(next)}
|
||||
aria-label={t("settings.mcp.enableToggleLabel")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1 text-xs tracking-wide uppercase">
|
||||
{t("settings.mcp.endpointLabel")}
|
||||
</p>
|
||||
<code className="bg-muted block rounded p-2 font-mono text-xs break-all">
|
||||
{endpoint}
|
||||
</code>
|
||||
<p className="text-muted-foreground mt-2 text-xs leading-relaxed">
|
||||
{t("settings.mcp.connectDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function McpTokensCard() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [tokenMsg, setTokenMsg] = useState<string | null>(null);
|
||||
const [tokenCopied, setTokenCopied] = useState(false);
|
||||
|
||||
const { data: tokens } = useQuery({
|
||||
queryKey: queryKeys.mcpTokens(),
|
||||
queryFn: async () => apiGet<McpTokenInfo[]>("/api/mcp/tokens"),
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setCreating(true);
|
||||
setTokenMsg(null);
|
||||
setNewToken(null);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/mcp/tokens", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newName.trim() }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (res.ok) {
|
||||
setNewToken(json.data.token);
|
||||
setNewName("");
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mcpTokens() });
|
||||
} else {
|
||||
setTokenMsg(json.error || t("common.error"));
|
||||
}
|
||||
} catch {
|
||||
setTokenMsg(t("common.networkError"));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyToken() {
|
||||
if (!newToken) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(newToken);
|
||||
setTokenCopied(true);
|
||||
toast.success(t("settings.tokenCopied"));
|
||||
setTimeout(() => setTokenCopied(false), 2_000);
|
||||
} catch {
|
||||
toast.error(t("settings.tokenCopyFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(tokenId: string) {
|
||||
try {
|
||||
const res = await apiFetchRaw(`/api/mcp/tokens/${tokenId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast.error(t("settings.tokenRevokeFailed"));
|
||||
return;
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mcpTokens() });
|
||||
} catch {
|
||||
toast.error(t("settings.tokenRevokeFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
const activeTokens = (tokens ?? []).filter((tok) => !tok.revoked);
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Key}
|
||||
title={t("settings.mcp.tokensTitle")}
|
||||
description={t("settings.mcp.tokensDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{t("settings.mcp.scopeNote")}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleCreate} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={t("settings.tokenNamePlaceholder")}
|
||||
maxLength={100}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="h-11 sm:h-10"
|
||||
disabled={creating || !newName.trim()}
|
||||
>
|
||||
{creating && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{newToken && (
|
||||
<div
|
||||
className="bg-success/10 rounded-lg p-3 text-sm"
|
||||
data-slot="settings-mcp-token-created"
|
||||
>
|
||||
<p className="text-success mb-1 font-medium">
|
||||
{t("settings.tokenCreated")}
|
||||
</p>
|
||||
<div className="flex items-start gap-2">
|
||||
<code className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all">
|
||||
{newToken}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 shrink-0 sm:h-9 sm:w-9"
|
||||
onClick={() => void handleCopyToken()}
|
||||
aria-label={t("settings.tokenCopy")}
|
||||
>
|
||||
{tokenCopied ? (
|
||||
<Check className="text-success h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tokenMsg && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{tokenMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">
|
||||
{t("settings.activeTokensTitle")}
|
||||
</p>
|
||||
{activeTokens.length === 0 ? (
|
||||
<p className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-sm">
|
||||
{t("settings.noActiveTokens")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{activeTokens.map((tok) => {
|
||||
const isExpired =
|
||||
tok.expiresAt && new Date(tok.expiresAt) < new Date();
|
||||
return (
|
||||
<li
|
||||
key={tok.id}
|
||||
className="bg-muted/30 border-border space-y-2 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 text-sm font-medium break-all">
|
||||
{tok.name}
|
||||
</p>
|
||||
{isExpired ? (
|
||||
<Badge variant="destructive" className="text-[10px]">
|
||||
{t("settings.tokenExpired")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-success/15 text-success text-[10px]">
|
||||
{t("settings.tokenActive")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTablePermissions")}:
|
||||
</span>{" "}
|
||||
{tok.permissions.join(", ")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTableCreated")}:
|
||||
</span>{" "}
|
||||
{formatDate(tok.createdAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTableLastUsed")}:
|
||||
</span>{" "}
|
||||
{tok.lastUsedAt
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</p>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive border-destructive/30 min-h-11 w-full"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("settings.tokenRevoke")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.tokenRevoke")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.tokenRevokeDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => handleRevoke(tok.id)}
|
||||
>
|
||||
{t("settings.tokenRevoked")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FlaskConical,
|
||||
Info,
|
||||
KeyRound,
|
||||
Plug,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
@@ -60,6 +61,7 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
|
||||
ai: Sparkles,
|
||||
coach: Bot,
|
||||
api: KeyRound,
|
||||
mcp: Plug,
|
||||
gesundheitsakte: FileHeart,
|
||||
sharing: Share2,
|
||||
export: Download,
|
||||
|
||||
@@ -73,6 +73,11 @@ export const SETTINGS_SECTION_SLUGS = [
|
||||
// the coach module. The `ai` entry keeps provider / model / BYOK only.
|
||||
"coach",
|
||||
"api",
|
||||
// v1.22.0 — the remote MCP connector card: enable the opt-in `mcp` module and
|
||||
// mint / revoke the dedicated `health:read` token used to connect an external
|
||||
// assistant (Claude.ai / ChatGPT). Sits next to `api` (both are programmatic
|
||||
// access surfaces). Not nav-gated — the card carries its own enable toggle.
|
||||
"mcp",
|
||||
"gesundheitsakte",
|
||||
// v1.18.7 — `sharing` (clinician share links) sits directly after
|
||||
// `gesundheitsakte`, before `export`: minting a time-boxed read-only link
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
FlaskConical,
|
||||
Info,
|
||||
KeyRound,
|
||||
Plug,
|
||||
LayoutDashboard,
|
||||
Layers,
|
||||
Link2,
|
||||
@@ -208,6 +209,10 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
moduleGate: "coach",
|
||||
},
|
||||
{ slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound },
|
||||
// v1.22.0 — remote MCP connector. Not module-gated: the card carries its own
|
||||
// enable toggle (mirroring `gesundheitsakte`), so the entry-point stays
|
||||
// reachable even with the opt-in module off.
|
||||
{ slug: "mcp", titleKey: "settings.sections.mcp.title", icon: Plug },
|
||||
// v1.18.0 (S5) — the full health record (PDF + FHIR R4 + zip package)
|
||||
// earns its own home, lifted out of Export & Import.
|
||||
//
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
*/
|
||||
export const integrationKeys = {
|
||||
tokens: () => ["tokens"] as const,
|
||||
// v1.22.0 — MCP connector tokens (the dedicated `health:read` scope minted
|
||||
// for the remote MCP endpoint). Distinct key from `tokens()` so the MCP
|
||||
// settings card's mint / revoke mutations invalidate only this list.
|
||||
mcpTokens: () => ["mcp-tokens"] as const,
|
||||
telegram: () => ["telegram"] as const,
|
||||
telegramSettings: () => ["telegram", "settings"] as const,
|
||||
withings: () => ["withings"] as const,
|
||||
|
||||
Reference in new issue
Block a user