diff --git a/messages/de.json b/messages/de.json index 0d3c2cedd..01f17e008 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1153,7 +1153,41 @@ "moodLogSyncResult": "{count} Einträge synchronisiert", "moodLogSyncFailed": "Sync fehlgeschlagen", "moodLogSaved": "moodLog-Verbindung gespeichert", - "moodLogDisconnected": "moodLog getrennt" + "moodLogDisconnected": "moodLog getrennt", + "about": { + "version": "App-Version", + "gitSha": "Build", + "builtAt": "Erstellt {time}", + "license": "Lizenz", + "repository": "Quellcode", + "changelog": "Changelog", + "docs": "Dokumentation", + "checkUpdates": "Auf Updates prüfen", + "upToDate": "Du nutzt die aktuelle Version", + "newerAvailable": "Neue Version: {tag}" + }, + "testConnection": { + "test": "Verbindung testen", + "testing": "Teste…", + "ok": "Verbunden (Latenz {latency} ms)", + "errors": { + "credentials_rejected": "Zugangsdaten abgelehnt — Token prüfen", + "rate_limited": "Vom Upstream rate-limitiert", + "timeout": "Request hat das Zeitlimit überschritten", + "upstream_error": "Upstream hat einen Fehler gemeldet", + "connection_failed": "Verbindung fehlgeschlagen", + "not_configured": "Nicht konfiguriert", + "url_not_public": "URL ist kein öffentlicher Endpunkt", + "url_invalid": "URL ist ungültig", + "redirected": "Endpunkt leitet weiter — URL prüfen", + "endpoint_not_found": "Endpunkt unter der URL nicht gefunden", + "credentials_unreadable": "Zugangsdaten nicht entschlüsselbar", + "upstream_invalid_json": "Upstream-Antwort ist kein JSON", + "vapid_not_configured": "Web Push nicht konfiguriert (VAPID-Keys fehlen)", + "rate_limited_self": "Zu viele Test-Anfragen", + "generic": "Test fehlgeschlagen" + } + } }, "admin": { "title": "Administration", diff --git a/messages/en.json b/messages/en.json index cffd9e743..3dcea5ee6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1153,7 +1153,41 @@ "moodLogSyncResult": "{count} entries synchronized", "moodLogSyncFailed": "Sync failed", "moodLogSaved": "moodLog connection saved", - "moodLogDisconnected": "moodLog disconnected" + "moodLogDisconnected": "moodLog disconnected", + "about": { + "version": "App version", + "gitSha": "Build", + "builtAt": "Built {time}", + "license": "License", + "repository": "Source code", + "changelog": "Changelog", + "docs": "Documentation", + "checkUpdates": "Check for updates", + "upToDate": "You are up to date", + "newerAvailable": "Newer version: {tag}" + }, + "testConnection": { + "test": "Test connection", + "testing": "Testing…", + "ok": "Connected (latency {latency} ms)", + "errors": { + "credentials_rejected": "Credentials rejected — check your token", + "rate_limited": "Rate-limited by the upstream", + "timeout": "Request timed out", + "upstream_error": "Upstream returned an error", + "connection_failed": "Connection failed", + "not_configured": "Not configured", + "url_not_public": "URL is not a public endpoint", + "url_invalid": "URL is invalid", + "redirected": "Endpoint redirects — check the URL", + "endpoint_not_found": "Endpoint not found at the URL", + "credentials_unreadable": "Credentials cannot be decrypted", + "upstream_invalid_json": "Upstream sent invalid JSON", + "vapid_not_configured": "Web Push not configured (VAPID keys missing)", + "rate_limited_self": "Too many test requests", + "generic": "Test failed" + } + } }, "admin": { "title": "Administration", diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index 6f2acb96e..31234c0b9 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -1,9 +1,19 @@ import { notFound } from "next/navigation"; +import type { JSX } from "react"; +import { AccountSection } from "@/components/settings/account-section"; +import { AboutSection } from "@/components/settings/about-section"; +import { AdvancedSection } from "@/components/settings/advanced-section"; +import { AiSection } from "@/components/settings/ai-section"; +import { ApiSection } from "@/components/settings/api-section"; +import { DashboardSection } from "@/components/settings/dashboard-section"; +import { IntegrationsSection } from "@/components/settings/integrations-section"; +import { NotificationsSection } from "@/components/settings/notifications-section"; import { SectionPlaceholder } from "@/components/settings/section-placeholder"; import { SETTINGS_SECTION_SLUGS, isSettingsSectionSlug, + type SettingsSectionSlug, } from "@/components/settings/section-slugs"; import { SettingsShell } from "@/components/settings/settings-shell"; @@ -22,6 +32,20 @@ export function generateStaticParams() { return SETTINGS_SECTION_SLUGS.map((section) => ({ section })); } +const SECTION_COMPONENTS: Record< + SettingsSectionSlug, + () => JSX.Element | null +> = { + account: AccountSection, + about: AboutSection, + ai: AiSection, + integrations: IntegrationsSection, + notifications: NotificationsSection, + dashboard: DashboardSection, + api: ApiSection, + advanced: AdvancedSection, +}; + interface PageProps { // Next.js 16 made route `params` an async Promise. We `await` it before use. params: Promise<{ section: string }>; @@ -37,9 +61,16 @@ export default async function SettingsSectionPage({ params }: PageProps) { notFound(); } - return ( - - - + const SectionComponent = SECTION_COMPONENTS[section]; + // Defensive fallback: in theory unreachable since `isSettingsSectionSlug` + // guards the slug, but a future slug added to `SETTINGS_SECTION_SLUGS` + // without a wired component would otherwise crash silently — placeholder + // surfaces the gap visually instead. + const body = SectionComponent ? ( + + ) : ( + ); + + return {body}; } diff --git a/src/app/settings/page.legacy.tsx b/src/app/settings/page.legacy.tsx deleted file mode 100644 index f892aacb3..000000000 --- a/src/app/settings/page.legacy.tsx +++ /dev/null @@ -1,3230 +0,0 @@ -// LEGACY: read-only reference for the v1.4 settings split. Delete after A2-rest lands. -// -// This file is the pre-A2 monolithic /settings page, preserved verbatim so the -// upcoming extraction PRs (A2-account, A2-about, A2-ai, A2-integrations, -// A2-notifications, A2-rest) can copy section-by-section into the new -// /settings/[section] routes. It is intentionally NOT a route — `page.tsx` (the -// active sibling) is a redirect, and Next.js only routes files literally named -// `page.tsx`. The default export was renamed to a non-default `LegacySettingsPage` -// so nothing accidentally imports it. -"use client"; - -// The legacy file is intentionally preserved verbatim and is NOT a route — -// disable the in-effect setState lint so the module compiles clean while -// section content is migrated. The new `/settings/[section]` routes follow -// the rule properly. -/* eslint-disable react-hooks/set-state-in-effect */ - -import { useState, useEffect } from "react"; -import { useAuth } from "@/hooks/use-auth"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Badge } from "@/components/ui/badge"; -import { Switch } from "@/components/ui/switch"; -import { - KeyRound, - Bell, - BellRing, - Loader2, - MessageCircle, - Save, - Send, - Shield, - Key, - Trash2, - Link2, - Unlink, - RefreshCw, - Download, - Sparkles, - AlertTriangle, - ChevronDown, - Eye, - EyeOff, - User, - Smile, -} from "lucide-react"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { formatDate, formatDateTime } from "@/lib/format"; -import { useTranslations } from "@/lib/i18n/context"; -import { locales, localeLabels, type Locale } from "@/lib/i18n/config"; -import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys"; -import { describePasskeyError } from "@/lib/passkey-errors"; -import { ThresholdsSection } from "@/components/settings/thresholds-section"; -import { DashboardLayoutSection } from "@/components/settings/dashboard-layout-section"; - -function PasswordInput(props: React.ComponentProps) { - const [visible, setVisible] = useState(false); - return ( -
- - -
- ); -} - -interface GlobalServiceAvailability { - telegramGlobal: boolean; - ntfyGlobal: boolean; - webPushGlobal: boolean; - apiGlobal: boolean; - moodLogGlobal: boolean; -} - -export function LegacySettingsPage() { - const { user, isLoading, isAuthenticated, refetch } = useAuth(); - const { t, locale, setLocale } = useTranslations(); - const queryClient = useQueryClient(); - const router = useRouter(); - - const [email, setEmail] = useState(""); - const [heightCm, setHeightCm] = useState(""); - const [dateOfBirth, setDateOfBirth] = useState(""); - const [gender, setGender] = useState(""); - const [saving, setSaving] = useState(false); - const [saveMsg, setSaveMsg] = useState(null); - const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>( - null, - ); - const [currentPassword, setCurrentPassword] = useState(""); - const [newPassword, setNewPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - const [passwordSaving, setPasswordSaving] = useState(false); - const [passwordMsg, setPasswordMsg] = useState(null); - const [passwordMsgType, setPasswordMsgType] = useState< - "success" | "error" | null - >(null); - const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); - const [passkeyLoading, setPasskeyLoading] = useState(false); - const [passkeyMsg, setPasskeyMsg] = useState(null); - const [passkeyMsgType, setPasskeyMsgType] = useState< - "success" | "error" | null - >(null); - - const { data: globalServices } = useQuery({ - queryKey: ["settings", "global-services"], - queryFn: async () => { - const res = await fetch("/api/settings/global-services"); - if (!res.ok) throw new Error("Failed"); - return (await res.json()).data as GlobalServiceAvailability; - }, - enabled: isAuthenticated, - }); - - useEffect(() => { - if (!isLoading && !isAuthenticated) { - router.push("/auth/login"); - } - }, [isLoading, isAuthenticated, router]); - - useEffect(() => { - if (user) { - setEmail(user.email ?? ""); - setHeightCm(user.heightCm?.toString() ?? ""); - setDateOfBirth( - user.dateOfBirth - ? new Date(user.dateOfBirth).toISOString().slice(0, 10) - : "", - ); - setGender(user.gender ?? ""); - } - }, [user]); - - async function handleSaveProfile(e: React.FormEvent) { - e.preventDefault(); - setSaving(true); - setSaveMsg(null); - setSaveMsgType(null); - - const res = await fetch("/api/auth/profile", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email: email.trim() || null, - heightCm: heightCm ? parseFloat(heightCm) : null, - dateOfBirth: dateOfBirth || null, - gender: gender || null, - }), - }); - - if (res.ok) { - setSaveMsg(t("settings.profileSaved")); - setSaveMsgType("success"); - await refetch(); - } else { - const json = await res.json(); - setSaveMsg(json.error || t("settings.savingError")); - setSaveMsgType("error"); - } - setSaving(false); - } - - async function handleAddPasskey() { - setPasskeyLoading(true); - setPasskeyMsg(null); - setPasskeyMsgType(null); - - try { - const optRes = await fetch("/api/auth/passkey/register-options", { - method: "POST", - }); - - if (!optRes.ok) { - setPasskeyMsg(t("settings.passkeyOptionsError")); - setPasskeyMsgType("error"); - setPasskeyLoading(false); - return; - } - - const optJson = await optRes.json(); - const { options, challengeId } = optJson.data; - - const { startRegistration } = await import("@simplewebauthn/browser"); - const credential = await startRegistration({ optionsJSON: options }); - - const verifyRes = await fetch("/api/auth/passkey/register-verify", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ challengeId, credential }), - }); - - if (verifyRes.ok) { - setPasskeyMsg(t("settings.passkeyAdded")); - setPasskeyMsgType("success"); - queryClient.invalidateQueries({ queryKey: ["passkeys"] }); - } else { - const verifyJson = await verifyRes.json(); - setPasskeyMsg( - verifyJson.error || t("settings.passkeyRegistrationFailed"), - ); - setPasskeyMsgType("error"); - } - } catch (err) { - const { key, params } = describePasskeyError(err); - setPasskeyMsg(t(key, params)); - setPasskeyMsgType("error"); - } finally { - setPasskeyLoading(false); - } - } - - async function handleChangePassword(e: React.FormEvent) { - e.preventDefault(); - setPasswordSaving(true); - setPasswordMsg(null); - setPasswordMsgType(null); - - if (newPassword !== confirmPassword) { - setPasswordMsg(t("settings.passwordMismatch")); - setPasswordMsgType("error"); - setPasswordSaving(false); - return; - } - - try { - const res = await fetch("/api/auth/password", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - currentPassword, - newPassword, - confirmPassword, - }), - }); - const json = await res.json(); - - if (!res.ok) { - setPasswordMsg(json.error || t("settings.savingError")); - setPasswordMsgType("error"); - return; - } - - setPasswordMsg(t("settings.passwordUpdated")); - setPasswordMsgType("success"); - setCurrentPassword(""); - setNewPassword(""); - setConfirmPassword(""); - } catch { - setPasswordMsg(t("common.networkError")); - setPasswordMsgType("error"); - } finally { - setPasswordSaving(false); - } - } - - if (isLoading) { - return ( -
- -
- ); - } - - if (!user) return null; - const serviceAvailability: GlobalServiceAvailability = { - telegramGlobal: globalServices?.telegramGlobal ?? true, - ntfyGlobal: globalServices?.ntfyGlobal ?? true, - webPushGlobal: globalServices?.webPushGlobal ?? true, - apiGlobal: globalServices?.apiGlobal ?? true, - moodLogGlobal: globalServices?.moodLogGlobal ?? true, - }; - const hasNotificationServices = - serviceAvailability.telegramGlobal || - serviceAvailability.ntfyGlobal || - serviceAvailability.webPushGlobal; - - return ( -
-
-

- {t("settings.title")} -

-

- {t("settings.subtitle")} -

-
- - {user.role === "ADMIN" && ( -
-
-
-
- -

- {t("settings.adminAreaTitle")} -

-
-

- {t("settings.adminAreaDescription")} -

-
- -
-
- )} - -
-
-

- {t("settings.categoryGeneral")} -

- - {/* Profile Section */} -
-
- -

{t("settings.profile")}

-
-
-
-
- - -
-
- - setEmail(e.target.value)} - placeholder={t("auth.emailPlaceholder")} - maxLength={320} - /> -
-
- -
-
- - -

- {t("settings.genderHint")} -

-
-
- - setHeightCm(e.target.value)} - placeholder="175" - min={50} - max={300} - step={0.1} - /> -
-
- -
-
- - setDateOfBirth(e.target.value)} - max={new Date().toISOString().slice(0, 10)} - /> -

- {t("settings.dateOfBirthHint")} -

-
-
- - -

- {t("settings.languageDescription")} -

-
-
- - {saveMsg && ( -

- {saveMsg} -

- )} - -
- -
-
-
-
- -
-

- {t("settings.security")} -

- -
-
- -

- {t("settings.passkeys")} -

-
- -
- - {passkeyMsg && ( -

- {passkeyMsg} -

- )} -
-
- -
-
-
- -

- {t("settings.passwordReset")} -

-
- -
-

- {t("settings.changePasswordDescription")} -

-
- - { - setPasswordDialogOpen(open); - if (!open) { - setCurrentPassword(""); - setNewPassword(""); - setConfirmPassword(""); - setPasswordMsg(null); - setPasswordMsgType(null); - } - }} - > - - - {t("settings.passwordReset")} - - {t("settings.changePasswordDescription")} - - - -
-
-
- - setCurrentPassword(e.target.value)} - /> -
-
- - setNewPassword(e.target.value)} - /> -
-
- - setConfirmPassword(e.target.value)} - /> -
-
- - {passwordMsg && ( -

- {passwordMsg} -

- )} - - -
-
-
-
- - {hasNotificationServices && ( -
-

- {t("settings.categoryNotifications")} -

- {serviceAvailability.telegramGlobal && ( - - )} - {serviceAvailability.ntfyGlobal && ( - - )} - {serviceAvailability.webPushGlobal && ( - - )} -
- )} - -
-

- {t("settings.categoryPersonalization")} -

- - -
- -
-

- {t("settings.categoryIntegration")} -

- - {serviceAvailability.moodLogGlobal !== false && ( - - )} - -
- - {serviceAvailability.apiGlobal && ( -
-

- {t("settings.categoryApi")} -

- - -
- )} - -
-

- {t("settings.export")} -

- -
- -
-

- {t("settings.dangerZoneTitle")} -

- -
-
-
- ); -} - -/* ─────────────────────── Passkey List ─────────────────────── */ - -interface PasskeyInfo { - id: string; - name: string; - credentialDeviceType: string; - credentialBackedUp: boolean; - createdAt: string; -} - -function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const [deleteMsg, setDeleteMsg] = useState(null); - - const { data: passkeys } = useQuery({ - queryKey: ["passkeys"], - queryFn: async () => { - const res = await fetch("/api/auth/passkeys"); - if (!res.ok) throw new Error("Failed"); - return (await res.json()).data as PasskeyInfo[]; - }, - enabled: isAuthenticated, - }); - - const deletePasskey = useMutation({ - mutationFn: async (id: string) => { - const res = await fetch(`/api/auth/passkeys/${id}`, { - method: "DELETE", - }); - const json = await res.json(); - if (!res.ok) throw new Error(json.error || t("common.error")); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["passkeys"] }); - setDeleteMsg(null); - }, - onError: (err: Error) => { - setDeleteMsg(err.message); - }, - }); - - const DEVICE_TYPE_LABELS: Record = { - singleDevice: t("settings.singleDevice"), - multiDevice: t("settings.multiDevice"), - }; - - if (!passkeys || passkeys.length === 0) { - return ( -
-

{t("settings.passkeys")}

-

- {t("settings.noPasskeys")} -

-
- ); - } - - return ( -
-

- {t("settings.registeredPasskeys")} -

-

- {t("settings.passkeysDescription")} -

-
- - - - - - - - - - - - {passkeys.map((pk, idx) => ( - - - - - - - - ))} - -
- {t("settings.passkeyName")} - - {t("settings.passkeyDevice")} - - {t("settings.passkeyBackup")} - - {t("settings.passkeyCreated")} - - {t("settings.passkeyActions")} -
{pk.name} - {DEVICE_TYPE_LABELS[pk.credentialDeviceType] ?? - pk.credentialDeviceType} - - - {pk.credentialBackedUp - ? t("settings.backedUp") - : t("common.no")} - - - {formatDate(pk.createdAt)} - - - - - - - - - {t("settings.deletePasskey")} - - - {t("settings.deletePasskeyDescription")} - - - - - {t("common.cancel")} - - deletePasskey.mutate(pk.id)} - > - {t("common.delete")} - - - - -
-
- {deleteMsg && ( -
- - {deleteMsg} -
- )} -
- ); -} - -/* ─────────────────────── Withings Integration ─────────────────────── */ - -function WithingsSection({ - id, - isAuthenticated, -}: { - id: string; - isAuthenticated: boolean; -}) { - const { t } = useTranslations(); - const [syncing, setSyncing] = useState(false); - const [syncMsg, setSyncMsg] = useState(null); - const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>( - null, - ); - const [clientId, setClientId] = useState(""); - const [clientSecret, setClientSecret] = useState(""); - const [credsSaving, setCredsSaving] = useState(false); - const [credsMsg, setCredsMsg] = useState(null); - const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>( - null, - ); - const queryClient = useQueryClient(); - - const { data: status } = useQuery({ - queryKey: ["withings", "status"], - queryFn: async () => { - const res = await fetch("/api/withings/status"); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as { - connected: boolean; - configured: boolean; - lastSyncedAt?: string | null; - connectedAt?: string; - tokenExpired?: boolean; - }; - }, - enabled: isAuthenticated, - }); - - const disconnect = useMutation({ - mutationFn: async () => { - const res = await fetch("/api/withings/disconnect", { method: "POST" }); - if (!res.ok) throw new Error("Failed"); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["withings"] }); - }, - }); - - async function handleSync(fullSync = false) { - setSyncing(true); - setSyncMsg(null); - setSyncMsgType(null); - try { - const res = await fetch("/api/withings/sync", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ fullSync }), - }); - const json = await res.json(); - if (res.ok) { - setSyncMsg( - fullSync - ? t("settings.withingsFullSyncResult", { - count: json.data.imported, - }) - : t("settings.withingsSyncResult", { count: json.data.imported }), - ); - setSyncMsgType("success"); - void invalidateKeys(queryClient, measurementDependentKeys); - } else { - setSyncMsg(json.error || t("settings.withingsSyncFailed")); - setSyncMsgType("error"); - } - } catch { - setSyncMsg(t("settings.withingsSyncFailed")); - setSyncMsgType("error"); - } finally { - setSyncing(false); - } - } - - async function handleSaveCredentials(e: React.FormEvent) { - e.preventDefault(); - setCredsSaving(true); - setCredsMsg(null); - setCredsMsgType(null); - - try { - const res = await fetch("/api/withings/credentials", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - clientId: clientId.trim(), - clientSecret: clientSecret.trim(), - }), - }); - - if (res.ok) { - setCredsMsg(t("settings.withingsCredentialsSaved")); - setCredsMsgType("success"); - setClientId(""); - setClientSecret(""); - queryClient.invalidateQueries({ queryKey: ["withings"] }); - } else { - try { - const json = await res.json(); - setCredsMsg(json.error || t("settings.savingError")); - } catch { - setCredsMsg(t("settings.savingError")); - } - setCredsMsgType("error"); - } - } catch { - setCredsMsg(t("common.networkError")); - setCredsMsgType("error"); - } - setCredsSaving(false); - } - - return ( -
-
-
- -

{t("settings.withings")}

-
-
- {status?.configured && !status?.connected && ( - - {t("settings.configured")} - - )} - {status?.connected && ( - - {t("settings.withingsConnected")} - - )} - {status?.lastSyncedAt && ( - - {t("settings.withingsLastSync")}:{" "} - {formatDateTime(status.lastSyncedAt)} - - )} - {status?.tokenExpired && ( - - {t("settings.withingsTokenExpired")} - - )} -
-
-

- {t("settings.withingsDescription")} -

- -
- {/* Credentials section */} -
-

- {t("settings.withingsCredentials")} -

-
-
-
- - setClientId(e.target.value)} - placeholder={ - status?.configured - ? t("settings.withingsCredentialsSavedPlaceholder") - : t("settings.withingsClientId") - } - maxLength={200} - /> -
-
- - setClientSecret(e.target.value)} - placeholder={ - status?.configured - ? t("settings.withingsCredentialsSavedPlaceholderSecret") - : t("settings.withingsClientSecret") - } - maxLength={200} - /> -
-
- - -
-
- {credsMsg && ( -

- {credsMsg} -

- )} -
-
- - {/* Connection status */} - {status?.connected ? ( - <> -
- - - - - - - - - {t("settings.withingsFullSyncTitle")} - - - {t("settings.withingsFullSyncDescription")} - - - - {t("common.cancel")} - handleSync(true)}> - {t("settings.withingsSynchronize")} - - - - - - - - - - - - {t("settings.withingsDisconnectTitle")} - - - {t("settings.withingsDisconnectDescription")} - - - - {t("common.cancel")} - disconnect.mutate()} - > - {t("settings.withingsDisconnect")} - - - - -
- {syncMsg && ( -

- {syncMsg} -

- )} - - ) : status?.configured ? ( - - ) : ( -
- {t("settings.withingsNoCredentials")} -
- )} -
-
- ); -} - -/* ─────────────────────── API Tokens Section ─────────────────────── */ - -function ApiEndpointsSection({ id }: { id: string }) { - const { t } = useTranslations(); - - return ( -
-
- -

- {t("settings.apiEndpointsTitle")} -

-
-

- {t("settings.apiEndpointsDescription")} -

- -
- - - - - - - - - - - - - - - - - -
- {t("settings.apiEndpointMethod")} - - {t("settings.apiEndpointPath")} - - {t("settings.apiEndpointAuth")} - - {t("settings.apiEndpointExample")} -
POST/api/ingest/medication - Authorization: Bearer hlk_... - - {`{ "medicationName": "...", "scheduledFor": "..." }`} -
-
-
- ); -} - -interface ApiTokenInfo { - id: string; - name: string; - permissions: string[]; - lastUsedAt: string | null; - expiresAt: string | null; - createdAt: string; - revoked: boolean; -} - -function ApiTokensSection({ - id, - isAuthenticated, -}: { - id: string; - isAuthenticated: boolean; -}) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const [newName, setNewName] = useState(""); - const [newToken, setNewToken] = useState(null); - const [creating, setCreating] = useState(false); - const [tokenMsg, setTokenMsg] = useState(null); - const [showRevokedTokens, setShowRevokedTokens] = useState(false); - - const { data: tokens } = useQuery({ - queryKey: ["tokens"], - queryFn: async () => { - const res = await fetch("/api/tokens"); - if (!res.ok) throw new Error("Failed"); - return (await res.json()).data as ApiTokenInfo[]; - }, - enabled: isAuthenticated, - }); - - async function handleCreate(e: React.FormEvent) { - e.preventDefault(); - setCreating(true); - setTokenMsg(null); - setNewToken(null); - - try { - const res = await fetch("/api/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: ["tokens"] }); - } else { - setTokenMsg(json.error || t("common.error")); - } - } catch { - setTokenMsg(t("common.networkError")); - } finally { - setCreating(false); - } - } - - async function handleRevoke(tokenId: string) { - const res = await fetch(`/api/tokens/${tokenId}`, { method: "DELETE" }); - if (res.ok) { - queryClient.invalidateQueries({ queryKey: ["tokens"] }); - } - } - - const activeTokens = (tokens ?? []).filter((tok) => !tok.revoked); - const revokedTokens = (tokens ?? []).filter((tok) => tok.revoked); - const latestActiveUse = activeTokens.reduce((latest, tok) => { - if (!tok.lastUsedAt) return latest; - if (!latest || new Date(tok.lastUsedAt) > new Date(latest)) { - return tok.lastUsedAt; - } - return latest; - }, null); - - return ( -
-
-
- -

{t("settings.apiTokens")}

-
-
- {activeTokens.length > 0 && ( - - {t("settings.configured")} - - )} - {latestActiveUse && ( - - {t("settings.tokenTableLastUsed")}:{" "} - {formatDateTime(latestActiveUse)} - - )} -
-
-

- {t("settings.apiTokensDescription")} -

- -
- {/* Create new token */} -
- setNewName(e.target.value)} - placeholder={t("settings.tokenNamePlaceholder")} - maxLength={100} - className="flex-1" - /> - -
- - {/* New token display */} - {newToken && ( -
-

- {t("settings.tokenCreated")} -

- - {newToken} - -
- )} - - {tokenMsg && ( -

- {tokenMsg} -

- )} - - {/* Token table (active) */} -
-

- {t("settings.activeTokensTitle")} -

-
- - - - - - - - - - - - - {activeTokens.length === 0 && ( - - - - )} - {activeTokens.map((tok, index) => { - const isExpired = - tok.expiresAt && new Date(tok.expiresAt) < new Date(); - return ( - - - - - - - - - ); - })} - -
- {t("settings.tokenTableName")} - - {t("settings.tokenTablePermissions")} - - {t("settings.tokenTableStatus")} - - {t("settings.tokenTableCreated")} - - {t("settings.tokenTableLastUsed")} - - {t("settings.tokenTableActions")} -
- {t("settings.noActiveTokens")} -
{tok.name} - {tok.permissions.join(", ")} - - {isExpired ? ( - - {t("settings.tokenExpired")} - - ) : ( - - {t("settings.tokenActive")} - - )} - - {formatDate(tok.createdAt)} - - {tok.lastUsedAt - ? formatDateTime(tok.lastUsedAt) - : t("settings.tokenNeverUsed")} - - - - - - - - - {t("settings.tokenRevoke")} - - - {t("settings.tokenRevokeDescription")} - - - - - {t("common.cancel")} - - handleRevoke(tok.id)} - > - {t("settings.tokenRevoked")} - - - - -
-
-
- - {/* Revoked tokens (collapsible, separate table) */} - {revokedTokens.length > 0 && ( -
- - {showRevokedTokens && ( -
- - - - - - - - - - - {revokedTokens.map((tok, index) => ( - - - - - - - ))} - -
- {t("settings.tokenTableName")} - - {t("settings.tokenTablePermissions")} - - {t("settings.tokenTableCreated")} - - {t("settings.tokenTableLastUsed")} -
{tok.name} - {tok.permissions.join(", ")} - - {formatDate(tok.createdAt)} - - {tok.lastUsedAt - ? formatDateTime(tok.lastUsedAt) - : t("settings.tokenNeverUsed")} -
-
- )} -
- )} -
-
- ); -} - -/* ─────────────────────── Export Section ─────────────────────── */ - -function ExportSection({ id }: { id: string }) { - const { t, locale } = useTranslations(); - const [exporting, setExporting] = useState(false); - const [generatingReport, setGeneratingReport] = useState(false); - - async function handleExport(format: "csv" | "json") { - setExporting(true); - try { - const res = await fetch(`/api/export?format=${format}&type=all`); - if (!res.ok) return; - - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `healthlog-export-${new Date().toISOString().slice(0, 10)}.${format}`; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); - } finally { - setExporting(false); - } - } - - async function handleDoctorReport() { - setGeneratingReport(true); - try { - const res = await fetch("/api/doctor-report", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ days: 90 }), - }); - if (!res.ok) return; - const json = await res.json(); - - const { generateDoctorReportPDF } = - await import("@/lib/doctor-report-pdf"); - const doc = generateDoctorReportPDF(json.data, { t, locale }); - const fileSlug = locale === "de" ? "gesundheitsbericht" : "health-report"; - doc.save(`${fileSlug}-${new Date().toISOString().slice(0, 10)}.pdf`); - } finally { - setGeneratingReport(false); - } - } - - return ( -
-
- -

{t("settings.export")}

-
-

- {t("settings.exportDescription")} -

-
- - - -
-
- ); -} - -/* ─────────────────────── Data Reset Section ─────────────────────── */ - -function DataResetSection({ id }: { id: string }) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const [deleting, setDeleting] = useState(false); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); - - async function handleDeleteAllData() { - setDeleting(true); - setMsg(null); - setMsgType(null); - try { - const res = await fetch("/api/settings/data", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ confirm: "DELETE" }), - }); - const json = await res.json(); - if (!res.ok) { - setMsg(json.error || t("settings.dangerZoneDeleteFailed")); - setMsgType("error"); - return; - } - - await queryClient.invalidateQueries(); - setMsg(t("settings.dangerZoneSuccess")); - setMsgType("success"); - } catch { - setMsg(t("settings.dangerZoneDeleteFailed")); - setMsgType("error"); - } finally { - setDeleting(false); - } - } - - return ( -
-
- -

- {t("settings.dangerZone")} -

-
-

- {t("settings.dangerZoneDescription")} -

- -
- - - - - - - - {t("settings.dangerZoneConfirm")} - - - {t("settings.dangerZoneConfirmDescription")} - - - - {t("common.cancel")} - - {t("settings.finalDelete")} - - - - -
- - {msg && ( -

- {msg} -

- )} -
- ); -} - -/* ─────────────────────── Telegram Notifications ─────────────────────── */ - -function TelegramSection({ - id, - isAuthenticated, -}: { - id: string; - isAuthenticated: boolean; -}) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const [botToken, setBotToken] = useState(""); - const [chatId, setChatId] = useState(""); - const [saving, setSaving] = useState(false); - const [testing, setTesting] = useState(false); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); - - const { data: settings } = useQuery({ - queryKey: ["telegram", "settings"], - queryFn: async () => { - const res = await fetch("/api/settings/telegram"); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as { - enabled: boolean; - hasBotToken: boolean; - chatId: string | null; - }; - }, - enabled: isAuthenticated, - }); - - const [enabled, setEnabled] = useState(false); - const [initialized, setInitialized] = useState(false); - if (settings && !initialized) { - setEnabled(settings.enabled); - if (settings.chatId) setChatId(settings.chatId); - setInitialized(true); - } - - async function handleSave(e: React.FormEvent) { - e.preventDefault(); - setSaving(true); - setMsg(null); - setMsgType(null); - - const body: Record = { enabled }; - if (botToken.trim()) body.botToken = botToken.trim(); - if (chatId !== (settings?.chatId ?? "")) body.chatId = chatId; - - const res = await fetch("/api/settings/telegram", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - - if (res.ok) { - setMsg(t("settings.telegramSaved")); - setMsgType("success"); - setBotToken(""); - queryClient.invalidateQueries({ queryKey: ["telegram"] }); - } else { - const json = await res.json(); - setMsg(json.error || t("settings.savingError")); - setMsgType("error"); - } - setSaving(false); - } - - async function handleTest() { - setTesting(true); - setMsg(null); - setMsgType(null); - - const res = await fetch("/api/settings/telegram/test", { method: "POST" }); - if (res.ok) { - setMsg(t("settings.testSent")); - setMsgType("success"); - } else { - const json = await res.json(); - setMsg(json.error || t("common.error")); - setMsgType("error"); - } - setTesting(false); - } - - return ( -
-
-
- -

{t("settings.telegram")}

-
-
- {settings?.hasBotToken && settings?.chatId && ( - - {t("settings.configured")} - - )} - {settings?.enabled && ( - - {t("common.enabled")} - - )} -
-
-

- {t("settings.telegramDescription")} -

- -
-
-
-
- - setBotToken(e.target.value)} - placeholder={ - settings?.hasBotToken - ? t("settings.withingsCredentialsSavedPlaceholder") - : "123456:ABC-DEF..." - } - maxLength={100} - /> -
-
- - setChatId(e.target.value)} - placeholder="123456789" - maxLength={50} - /> -
-
- -
- - -
- -
-

{t("settings.telegramStep1")}

-

{t("settings.telegramStep2")}

-

{t("settings.telegramStep3")}

-
- - {msg && ( -

- {msg} -

- )} - -
- - -
-
-
-
- ); -} - -/* ─────────────────────── ntfy Settings ─────────────────────── */ - -interface NtfySettings { - enabled: boolean; - serverUrl: string; - topic: string; - hasAuthToken: boolean; -} - -function NtfySection({ - id, - isAuthenticated, -}: { - id: string; - isAuthenticated: boolean; -}) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const [serverUrl, setServerUrl] = useState("https://ntfy.sh"); - const [topic, setTopic] = useState(""); - const [authToken, setAuthToken] = useState(""); - const [saveMsg, setSaveMsg] = useState(null); - const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>( - null, - ); - - const { data: settings } = useQuery({ - queryKey: ["settings", "ntfy"], - queryFn: async () => { - const res = await fetch("/api/settings/ntfy"); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as NtfySettings; - }, - enabled: isAuthenticated, - }); - - // Sync from server - useState(() => { - if (settings) { - setServerUrl(settings.serverUrl); - setTopic(settings.topic); - } - }); - - const save = useMutation({ - mutationFn: async (enabled: boolean) => { - const res = await fetch("/api/settings/ntfy", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - serverUrl, - topic, - authToken: authToken || undefined, - enabled, - }), - }); - if (!res.ok) { - const json = await res.json(); - throw new Error(json.error || t("common.error")); - } - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["settings", "ntfy"] }); - setSaveMsg(t("settings.saved")); - setSaveMsgType("success"); - setAuthToken(""); - }, - onError: (err: Error) => { - setSaveMsg(err.message); - setSaveMsgType("error"); - }, - }); - - const test = useMutation({ - mutationFn: async () => { - const res = await fetch("/api/settings/ntfy/test", { method: "POST" }); - if (!res.ok) { - const json = await res.json(); - throw new Error(json.error || t("common.error")); - } - }, - onSuccess: () => { - setSaveMsg(t("settings.testSent")); - setSaveMsgType("success"); - }, - onError: (err: Error) => { - setSaveMsg(err.message); - setSaveMsgType("error"); - }, - }); - - return ( -
-
-
- -

{t("settings.ntfy")}

-
-
- {settings?.serverUrl && settings?.topic && ( - - {t("settings.configured")} - - )} - {settings?.enabled && ( - - {t("common.enabled")} - - )} -
-
-

- {t("settings.ntfyDescription")} -

- -
-
- - save.mutate(checked)} - disabled={save.isPending} - /> -
- -
{ - e.preventDefault(); - save.mutate(settings?.enabled ?? false); - }} - className="space-y-4" - > -
-
- - setServerUrl(e.target.value)} - /> -
-
- - setTopic(e.target.value)} - /> -
-
- - setAuthToken(e.target.value)} - /> -

- {t("settings.ntfyAuthTokenHint")} -

-
-
- - {saveMsg && ( -

- {saveMsg} -

- )} - -
- - -
-
-
-
- ); -} - -/* ─────────────────────── Web Push Notifications ─────────────────────── */ - -function WebPushSection({ id }: { id: string }) { - const { t } = useTranslations(); - const [isSubscribed, setIsSubscribed] = useState(false); - const [isSupported, setIsSupported] = useState(true); - const [isDenied, setIsDenied] = useState(false); - const [loading, setLoading] = useState(true); - const [actionLoading, setActionLoading] = useState(false); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); - - useEffect(() => { - async function checkSubscription() { - if (!("serviceWorker" in navigator) || !("PushManager" in window)) { - setIsSupported(false); - setLoading(false); - return; - } - - if (Notification.permission === "denied") { - setIsDenied(true); - setLoading(false); - return; - } - - try { - const registration = - await navigator.serviceWorker.getRegistration("/sw.js"); - if (registration) { - const subscription = await registration.pushManager.getSubscription(); - setIsSubscribed(!!subscription); - } - } catch { - // Ignore errors during check - } - setLoading(false); - } - - checkSubscription(); - }, []); - - async function handleSubscribe() { - setActionLoading(true); - setMsg(null); - setMsgType(null); - - try { - // Get VAPID public key from server - const vapidRes = await fetch("/api/notifications/vapid"); - if (!vapidRes.ok) { - setMsg(t("settings.webPushNotConfigured")); - setMsgType("error"); - setActionLoading(false); - return; - } - const vapidData = await vapidRes.json(); - const vapidPublicKey = vapidData.data.publicKey; - - // Register service worker - const registration = await navigator.serviceWorker.register("/sw.js"); - await navigator.serviceWorker.ready; - - // Subscribe to push - const subscription = await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToArrayBuffer(vapidPublicKey), - }); - - const subJson = subscription.toJSON(); - - // Save subscription on server - const res = await fetch("/api/notifications/web-push", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - endpoint: subJson.endpoint, - keys: { - p256dh: subJson.keys?.p256dh ?? "", - auth: subJson.keys?.auth ?? "", - }, - }), - }); - - if (res.ok) { - setIsSubscribed(true); - setMsg(t("settings.webPushSubscribed")); - setMsgType("success"); - } else { - setMsg(t("settings.webPushSubscribeFailed")); - setMsgType("error"); - } - } catch { - if (Notification.permission === "denied") { - setIsDenied(true); - setMsg(t("settings.webPushDenied")); - } else { - setMsg(t("settings.webPushSubscribeFailed")); - } - setMsgType("error"); - } finally { - setActionLoading(false); - } - } - - async function handleUnsubscribe() { - setActionLoading(true); - setMsg(null); - setMsgType(null); - - try { - const registration = - await navigator.serviceWorker.getRegistration("/sw.js"); - if (registration) { - const subscription = await registration.pushManager.getSubscription(); - if (subscription) { - // Delete from server - await fetch("/api/notifications/web-push", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ endpoint: subscription.endpoint }), - }); - - await subscription.unsubscribe(); - } - } - - setIsSubscribed(false); - setMsg(t("settings.webPushUnsubscribed")); - setMsgType("success"); - } catch { - setMsg(t("settings.webPushSubscribeFailed")); - setMsgType("error"); - } finally { - setActionLoading(false); - } - } - - return ( -
-
-
- -

{t("settings.webPush")}

-
-
- {!loading && isSubscribed && ( - - {t("settings.configured")} - - )} - {!loading && isSubscribed && ( - - {t("settings.webPushActive")} - - )} -
-
-

- {t("settings.webPushDescription")} -

- -
- {loading ? ( -
- -
- ) : !isSupported ? ( -

- {t("settings.webPushNotSupported")} -

- ) : isDenied ? ( -

- {t("settings.webPushDenied")} -

- ) : isSubscribed ? ( - - ) : ( - - )} - - {msg && ( -

- {msg} -

- )} -
-
- ); -} - -/** Convert a URL-safe base64 string to an ArrayBuffer (for VAPID key). */ -function urlBase64ToArrayBuffer(base64String: string): ArrayBuffer { - const padding = "=".repeat((4 - (base64String.length % 4)) % 4); - const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); - const raw = atob(base64); - const buffer = new ArrayBuffer(raw.length); - const view = new Uint8Array(buffer); - for (let i = 0; i < raw.length; ++i) { - view[i] = raw.charCodeAt(i); - } - return buffer; -} - -/* ─────────────────────── moodLog Integration ─────────────────────── */ - -function MoodLogSection({ t }: { t: (key: string) => string }) { - const [url, setUrl] = useState(""); - const [apiKey, setApiKey] = useState(""); - const [saving, setSaving] = useState(false); - const [syncing, setSyncing] = useState(false); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); - - const { data: status, refetch: refetchStatus } = useQuery({ - queryKey: ["moodlog-status"], - queryFn: async () => { - const res = await fetch("/api/integrations/moodlog/status"); - if (!res.ok) throw new Error("Failed"); - return (await res.json()).data as { - configured: boolean; - enabled: boolean; - lastSyncedAt: string | null; - entryCount: number; - webhookSecret: string | null; - }; - }, - }); - - async function handleSave(e: React.FormEvent) { - e.preventDefault(); - setSaving(true); - setMsg(null); - const res = await fetch("/api/settings/moodlog", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url: url.trim(), apiKey: apiKey.trim() }), - }); - if (res.ok) { - setMsg(t("settings.moodLogSaved")); - setMsgType("success"); - setUrl(""); - setApiKey(""); - await refetchStatus(); - } else { - const json = await res.json(); - setMsg(json.error || t("settings.savingError")); - setMsgType("error"); - } - setSaving(false); - } - - async function handleSync(fullSync = false) { - setSyncing(true); - setMsg(null); - try { - const res = await fetch("/api/integrations/moodlog/sync", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ fullSync }), - }); - if (res.ok) { - const json = await res.json(); - setMsg( - t("settings.moodLogSyncResult").replace( - "{count}", - String(json.data.imported), - ), - ); - setMsgType("success"); - await refetchStatus(); - } else { - setMsg(t("settings.moodLogSyncFailed")); - setMsgType("error"); - } - } catch { - setMsg(t("settings.moodLogSyncFailed")); - setMsgType("error"); - } - setSyncing(false); - } - - async function handleDisconnect() { - const res = await fetch("/api/settings/moodlog", { method: "DELETE" }); - if (res.ok) { - setMsg(t("settings.moodLogDisconnected")); - setMsgType("success"); - await refetchStatus(); - } - } - - return ( -
-
- - {status?.configured && ( - - {status.enabled - ? t("settings.withingsConnected") - : t("settings.configured")} - - )} -
-

- {t("settings.moodLogDescription")} -

- - {/* Credentials form */} -
-
- - setUrl(e.target.value)} - /> -
-
- - setApiKey(e.target.value)} - /> -
- -
- - {/* Connected state */} - {status?.configured && ( -
- {/* Webhook Secret */} - {status.webhookSecret && ( -
- -
- - -
-

- {t("settings.moodLogWebhookSecretHelp")} -

-
- )} - - {/* Status info */} -
- {status.lastSyncedAt && ( - - {t("settings.moodLogLastSync")}:{" "} - {formatDateTime(status.lastSyncedAt)} - - )} - - {t("settings.moodLogEntries")}: {status.entryCount} - -
- - {/* Sync buttons */} -
- - - - - - - - - - {t("settings.moodLogFullSyncTitle")} - - - {t("settings.moodLogFullSyncDescription")} - - - - {t("common.cancel")} - handleSync(true)}> - {t("settings.moodLogFullSync")} - - - - - - - - - - - - - {t("settings.moodLogDisconnectTitle")} - - - {t("settings.moodLogDisconnectDescription")} - - - - {t("common.cancel")} - - {t("settings.moodLogDisconnect")} - - - - -
-
- )} - - {/* Status message */} - {msg && ( -

- {msg} -

- )} -
- ); -} - -/* ─────────────────────── OpenAI Insights Settings ─────────────────────── */ - -function InsightsSettingsSection({ - id, - isAuthenticated, -}: { - id: string; - isAuthenticated: boolean; -}) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const [disconnecting, setDisconnecting] = useState(false); - const [regenerating, setRegenerating] = useState(false); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); - - const { data: settings } = useQuery({ - queryKey: ["insights", "settings"], - queryFn: async () => { - const res = await fetch("/api/insights/settings"); - if (!res.ok) return null; - const json = await res.json(); - return json.data as { - codexStatus: string; - codexConnectedAt: string | null; - hasAdminKey: boolean; - privacyMode: string; - lastInsightAt: string | null; - }; - }, - enabled: isAuthenticated, - }); - - const updateSettings = useMutation({ - mutationFn: async (body: Record) => { - const res = await fetch("/api/insights/settings", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - const json = await res.json(); - if (!res.ok) throw new Error(json.error); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["insights"] }); - }, - }); - - // Handle OAuth callback query params - useEffect(() => { - const params = new URLSearchParams(window.location.search); - if (params.get("codex_connected") === "true") { - setMsg(t("settings.codexConnected")); - setMsgType("success"); - queryClient.invalidateQueries({ queryKey: ["insights"] }); - window.history.replaceState({}, "", window.location.pathname); - } else if (params.get("codex_error")) { - setMsg(t("settings.codexConnectionFailed")); - setMsgType("error"); - window.history.replaceState({}, "", window.location.pathname); - } - }, [queryClient, t]); - - const hasProvider = - settings?.codexStatus === "connected" || settings?.hasAdminKey; - - async function handleConnect() { - window.location.href = "/api/auth/codex/authorize"; - } - - async function handleDisconnect() { - setDisconnecting(true); - setMsg(null); - try { - const res = await fetch("/api/auth/codex/disconnect", { - method: "DELETE", - }); - if (!res.ok) { - const json = await res.json(); - throw new Error(json.error); - } - setMsg(t("settings.codexDisconnected")); - setMsgType("success"); - queryClient.invalidateQueries({ queryKey: ["insights"] }); - } catch (err) { - setMsg(err instanceof Error ? err.message : t("settings.savingError")); - setMsgType("error"); - } finally { - setDisconnecting(false); - } - } - - async function togglePrivacyMode() { - const newMode = settings?.privacyMode === "raw" ? "aggregated" : "raw"; - await updateSettings.mutateAsync({ privacyMode: newMode }); - } - - async function handleRegenerate() { - setRegenerating(true); - setMsg(null); - setMsgType(null); - try { - const res = await fetch("/api/insights/generate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ force: true }), - }); - const json = await res.json(); - if (!res.ok) { - if (res.status === 429) { - setMsg(t("settings.regenerateRateLimit")); - } else { - setMsg(json.error || t("settings.savingError")); - } - setMsgType("error"); - return; - } - setMsg(t("settings.regenerateSuccess")); - setMsgType("success"); - queryClient.invalidateQueries({ queryKey: ["insights"] }); - } catch { - setMsg(t("settings.savingError")); - setMsgType("error"); - } finally { - setRegenerating(false); - } - } - - return ( -
-
-
- -

{t("settings.kiInsights")}

-
-
- {settings?.codexStatus === "connected" && ( - - ChatGPT verbunden - - )} - {settings?.codexStatus !== "connected" && settings?.hasAdminKey && ( - - Admin-KI aktiv - - )} - {settings?.codexStatus === "expired" && ( - - Verbindung abgelaufen - - )} - {settings?.lastInsightAt && ( - - {t("settings.lastGeneratedAt")}:{" "} - {formatDateTime(settings.lastInsightAt)} - - )} -
-
-

- {t("settings.kiInsightsDescription")} -

- -
- {/* ChatGPT Connection */} -
- {settings?.codexStatus === "connected" ? ( -
-
-

ChatGPT verbunden

-

- Insights werden über dein ChatGPT-Abo generiert — keine - zusätzlichen Kosten. - {settings.codexConnectedAt && ( - <> - {" "} - Verbunden seit {formatDateTime(settings.codexConnectedAt)} - . - - )} -

-
- -
- ) : ( -
-
-

Mit ChatGPT verbinden

-

- Verbinde dein ChatGPT Pro/Max-Konto um KI-gestützte - Gesundheitsanalysen basierend auf aktuellen medizinischen - Leitlinien zu erhalten. Keine zusätzlichen API-Kosten. -

-
- - {settings?.hasAdminKey && ( -

- Alternativ nutzt HealthLog den vom Administrator - konfigurierten KI-Anbieter. -

- )} -
- )} -
- - {msg && ( -

- {msg} -

- )} - - {/* Privacy Mode */} - {hasProvider && ( -
-
-
-

{t("settings.rawData")}

-

- {settings?.privacyMode === "raw" - ? t("settings.rawDataOnDescription") - : t("settings.rawDataOffDescription")} -

-
-
- -
-
- {settings?.privacyMode === "raw" && ( -
- {t("settings.rawDataWarning")} -
- )} -
- )} - - {/* Regenerate Reports */} - {hasProvider && ( - - )} - - {/* Multi-provider AI selection (per-user override) */} - -
-
- ); -} - -/* ─────────────────────── User AI Provider Override ─────────────────────── */ - -function UserAIProviderSubsection() { - const queryClient = useQueryClient(); - const [provider, setProvider] = useState(""); - const [model, setModel] = useState(""); - const [baseUrl, setBaseUrl] = useState(""); - const [anthropicKey, setAnthropicKey] = useState(""); - const [localKey, setLocalKey] = useState(""); - const [saveMsg, setSaveMsg] = useState(null); - const [saveOk, setSaveOk] = useState(false); - const [testMsg, setTestMsg] = useState(null); - const [testOk, setTestOk] = useState(false); - const [testing, setTesting] = useState(false); - - const { data, isLoading } = useQuery({ - queryKey: ["user", "ai-provider"], - queryFn: async () => { - const res = await fetch("/api/user/ai-provider"); - if (!res.ok) return null; - const json = await res.json(); - return json.data as { - provider: string | null; - model: string | null; - baseUrl: string | null; - hasAnthropicKey: boolean; - anthropicKeyPreview: string | null; - hasLocalKey: boolean; - }; - }, - }); - - useEffect(() => { - if (!data) return; - // Sync-from-server pattern: form draft state is initialised from the - // fetched user record. Could be reworked into a derived-state approach, - // but that's a non-trivial refactor of this monolithic settings page — - // tracked for the 1.4.0 settings split. - setProvider(data.provider ?? ""); - setModel(data.model ?? ""); - setBaseUrl(data.baseUrl ?? ""); - }, [data]); - - const saveMutation = useMutation({ - mutationFn: async () => { - const body: Record = { - provider: provider || null, - model: model || null, - baseUrl: baseUrl || null, - }; - if (anthropicKey.trim()) body.anthropicKey = anthropicKey.trim(); - if (localKey.trim()) body.localKey = localKey.trim(); - const res = await fetch("/api/user/ai-provider", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - const json = await res.json(); - if (!res.ok) throw new Error(json.error || "Save failed"); - }, - onSuccess: () => { - setSaveMsg("Gespeichert"); - setSaveOk(true); - setAnthropicKey(""); - setLocalKey(""); - queryClient.invalidateQueries({ queryKey: ["user", "ai-provider"] }); - queryClient.invalidateQueries({ queryKey: ["insights"] }); - }, - onError: (e) => { - setSaveMsg(e instanceof Error ? e.message : "Fehler"); - setSaveOk(false); - }, - }); - - async function runTest() { - setTesting(true); - setTestMsg(null); - try { - // v1.4: pass the *current* dropdown / input selection so the test - // honours unsaved changes. Plaintext keys never persist server-side. - const overrideBody: Record = {}; - if (provider) overrideBody.provider = provider; - if (model.trim()) overrideBody.model = model.trim(); - if (baseUrl.trim()) overrideBody.baseUrl = baseUrl.trim(); - if (anthropicKey.trim()) overrideBody.anthropicKey = anthropicKey.trim(); - if (localKey.trim()) overrideBody.localKey = localKey.trim(); - const hasOverride = Object.keys(overrideBody).length > 0; - const res = await fetch("/api/ai/test", { - method: "POST", - headers: hasOverride ? { "Content-Type": "application/json" } : {}, - body: hasOverride ? JSON.stringify(overrideBody) : undefined, - }); - const json = await res.json(); - if (!res.ok) { - setTestMsg(json.error || `HTTP ${res.status}`); - setTestOk(false); - return; - } - setTestMsg( - `OK — ${json.data.providerType} (${json.data.model})${ - json.data.tokensUsed ? `, ${json.data.tokensUsed} tokens` : "" - }`, - ); - setTestOk(true); - } catch (e) { - setTestMsg(e instanceof Error ? e.message : "Test fehlgeschlagen"); - setTestOk(false); - } finally { - setTesting(false); - } - } - - if (isLoading) return null; - - return ( -
-
-

KI-Provider (persönlich)

-

- Eigener KI-Anbieter überschreibt die Admin-Einstellung. Leer lassen - für Standard. -

-
- -
-
- - -
- -
- - setModel(e.target.value)} - placeholder={ - provider === "ANTHROPIC" - ? "claude-3-5-sonnet-latest" - : provider === "LOCAL" - ? "llama3:8b" - : "gpt-4o-mini" - } - className="mt-1" - /> -
- - {provider === "ANTHROPIC" && ( -
- - setAnthropicKey(e.target.value)} - placeholder="sk-ant-..." - className="mt-1" - /> -
- )} - - {provider === "LOCAL" && ( - <> -
- - setBaseUrl(e.target.value)} - placeholder="http://localhost:11434/v1" - className="mt-1" - /> -
-
- - setLocalKey(e.target.value)} - placeholder={data?.hasLocalKey ? "(gespeichert)" : ""} - className="mt-1" - /> -
- - )} -
- -
- - -
- - {saveMsg && ( -

- {saveMsg} -

- )} - {testMsg && ( -

- {testMsg} -

- )} -
- ); -} diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx new file mode 100644 index 000000000..7039fd19f --- /dev/null +++ b/src/components/settings/__tests__/sections.test.tsx @@ -0,0 +1,146 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +// Stub out next/navigation. The settings sections that read it (Account) +// only call `useRouter().push()` from inside an effect after mount, so a +// no-op stub is enough for SSR smoke rendering. +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings/account", +})); + +// Stub TanStack Query — every section reads from it but we only need the +// SSR markup, not live data. Returning a stable empty payload keeps the +// "loading" branch from spinning forever and lets the section title render. +// The About section reads `["api", "version"]` and gates the body on data +// presence — return a representative payload so the version card paints. +vi.mock("@tanstack/react-query", () => ({ + useQuery: ({ queryKey }: { queryKey: unknown[] }) => { + if ( + Array.isArray(queryKey) && + queryKey[0] === "api" && + queryKey[1] === "version" + ) { + return { + data: { + version: "1.4.0", + buildSha: "abc1234567", + builtAt: "2026-05-08T12:00:00Z", + license: "AGPL-3.0", + repository: "https://github.com/MBombeck/HealthLog", + changelog: + "https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md", + docs: "https://docs.healthlog.dev", + }, + isLoading: false, + refetch: vi.fn(), + }; + } + return { data: null, isLoading: false, refetch: vi.fn() }; + }, + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); + +// Stub the auth hook — sections check `isAuthenticated` to enable queries. +// We return `isAuthenticated: true` + a minimal user so the loading-spinner +// branch in AccountSection paints the real form. +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { + id: "u1", + username: "marc", + email: "marc@example.com", + heightCm: 180, + dateOfBirth: "1990-01-01", + gender: "MALE", + role: "USER", + }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +// Sub-sections of Dashboard own complex data fetching that's already +// covered by their own tests. Stub at the import boundary. +vi.mock("@/components/settings/dashboard-layout-section", () => ({ + DashboardLayoutSection: () =>
, +})); +vi.mock("@/components/settings/thresholds-section", () => ({ + ThresholdsSection: () =>
, +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { AboutSection } from "../about-section"; +import { AccountSection } from "../account-section"; +import { AdvancedSection } from "../advanced-section"; +import { AiSection } from "../ai-section"; +import { ApiSection } from "../api-section"; +import { DashboardSection } from "../dashboard-section"; +import { IntegrationsSection } from "../integrations-section"; +import { NotificationsSection } from "../notifications-section"; + +function render(node: React.ReactElement, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +describe("settings sections — SSR smoke", () => { + it(" renders the Account heading and Profile card", () => { + const html = render(); + // Section title resolves via the i18n provider. + expect(html).toContain("Account"); + // Profile card heading is also painted. + expect(html).toContain("Profile"); + // No raw key leaks past the i18n layer. + expect(html).not.toContain("settings.sections."); + }); + + it(" renders version + license + check-for-updates button", () => { + const html = render(); + expect(html).toContain("About"); + // The new about.* keys resolve. + expect(html).toContain("Check for updates"); + expect(html).not.toContain("settings.about."); + }); + + it(" renders the AI Insights card", () => { + const html = render(); + expect(html).toContain("AI Insights"); + }); + + it(" renders Withings card title", () => { + const html = render(); + expect(html).toContain("Integrations"); + expect(html).toContain("Withings"); + }); + + it(" renders heading", () => { + const html = render(); + expect(html).toContain("Notifications"); + }); + + it(" renders heading and embeds layout + thresholds", () => { + const html = render(); + expect(html).toContain("Dashboard"); + expect(html).toContain('data-testid="dashboard-layout"'); + expect(html).toContain('data-testid="thresholds"'); + }); + + it(" renders endpoints + tokens cards", () => { + const html = render(); + expect(html).toContain("API & Tokens"); + }); + + it(" renders export + danger-zone cards", () => { + const html = render(); + expect(html).toContain("Advanced"); + }); + + it(" resolves about.* keys in German", () => { + const html = render(, "de"); + expect(html).toContain("Auf Updates prüfen"); + }); +}); diff --git a/src/components/settings/about-section.tsx b/src/components/settings/about-section.tsx new file mode 100644 index 000000000..46e8f2b19 --- /dev/null +++ b/src/components/settings/about-section.tsx @@ -0,0 +1,308 @@ +"use client"; + +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + BookOpen, + CheckCircle2, + ExternalLink, + GitBranch, + Info, + Loader2, + Package, + RefreshCw, + Scale, + Sparkles, + XCircle, +} from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; + +interface VersionPayload { + version: string; + buildSha: string | null; + builtAt: string | null; + license: string; + repository: string; + changelog: string; + docs: string; +} + +interface GithubRelease { + tag_name: string; + html_url: string; + name: string; +} + +const RELEASES_API_URL = + "https://api.github.com/repos/MBombeck/HealthLog/releases/latest"; + +/** + * Compare a semantic version (e.g. "1.4.0") with a release tag (e.g. + * "v1.4.0", "1.4.1"). Returns `true` when `tag` is strictly newer than + * `current`. Falls back to lexical compare when the strings don't parse, + * which is intentional — better to surface "newer available" on a parse miss + * than silently swallow a release. + */ +function isNewer(current: string, tag: string): boolean { + const stripV = (s: string) => s.replace(/^v/i, "").trim(); + const a = stripV(current) + .split(/[.-]/) + .map((n) => Number.parseInt(n, 10)); + const b = stripV(tag) + .split(/[.-]/) + .map((n) => Number.parseInt(n, 10)); + const maxLen = Math.max(a.length, b.length); + for (let i = 0; i < maxLen; i++) { + const ai = a[i] ?? 0; + const bi = b[i] ?? 0; + if (Number.isNaN(ai) || Number.isNaN(bi)) { + // Fall back to lexical compare for pre-release suffixes etc. + return stripV(tag) > stripV(current); + } + if (bi > ai) return true; + if (bi < ai) return false; + } + return false; +} + +export function AboutSection() { + const { t } = useTranslations(); + const fmt = useFormatters(); + + const { data: version, isLoading } = useQuery({ + queryKey: ["api", "version"], + queryFn: async () => { + const res = await fetch("/api/version"); + if (!res.ok) throw new Error("version-fetch-failed"); + const json = await res.json(); + return json.data as VersionPayload; + }, + // The endpoint is `force-static` — version doesn't change at runtime. + staleTime: Infinity, + }); + + const [checking, setChecking] = useState(false); + const [updateResult, setUpdateResult] = useState< + | { kind: "up_to_date" } + | { kind: "newer"; tag: string; url: string } + | { kind: "error" } + | null + >(null); + + async function handleCheckForUpdates() { + if (!version) return; + setChecking(true); + setUpdateResult(null); + try { + const res = await fetch(RELEASES_API_URL, { + headers: { Accept: "application/vnd.github+json" }, + }); + if (!res.ok) { + setUpdateResult({ kind: "error" }); + return; + } + const release = (await res.json()) as GithubRelease; + if (isNewer(version.version, release.tag_name)) { + setUpdateResult({ + kind: "newer", + tag: release.tag_name, + url: release.html_url, + }); + } else { + setUpdateResult({ kind: "up_to_date" }); + } + } catch { + setUpdateResult({ kind: "error" }); + } finally { + setChecking(false); + } + } + + return ( +
+
+

+ {t("settings.sections.about.title")} +

+

+ {t("settings.sections.about.description")} +

+
+ +
+
+ +

HealthLog

+
+ + {isLoading || !version ? ( +
+ + + {t("common.loading")} + +
+ ) : ( +
+
+ +
+
+ {t("settings.about.version")} +
+
+ v{version.version} +
+
+
+ +
+ +
+
+ {t("settings.about.license")} +
+
+ + {version.license} + +
+
+
+ + {version.buildSha && ( +
+ +
+
+ {t("settings.about.gitSha")} +
+
+ {version.buildSha.slice(0, 7)} +
+
+
+ )} + + {version.builtAt && ( +
+ +
+
+ {t("settings.about.builtAt", { + time: fmt.dateTime(version.builtAt), + })} +
+
+ {fmt.date(version.builtAt)} +
+
+
+ )} +
+ )} +
+ + {/* Links */} + {version && ( + + )} + + {/* Check for updates */} + {version && ( +
+ + + {updateResult?.kind === "up_to_date" && ( +

+ + {t("settings.about.upToDate")} +

+ )} + + {updateResult?.kind === "newer" && ( +

+ + + {t("settings.about.newerAvailable", { + tag: updateResult.tag, + })} + +

+ )} + + {updateResult?.kind === "error" && ( +

+ + {t("settings.testConnection.errors.connection_failed")} +

+ )} +
+ )} +
+ ); +} diff --git a/src/components/settings/account-section.tsx b/src/components/settings/account-section.tsx new file mode 100644 index 000000000..901b2d335 --- /dev/null +++ b/src/components/settings/account-section.tsx @@ -0,0 +1,664 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + AlertTriangle, + KeyRound, + Loader2, + Save, + Shield, + Trash2, + User, +} from "lucide-react"; + +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 { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { PasswordInput } from "@/components/settings/password-input"; +import { useAuth } from "@/hooks/use-auth"; +import { formatDate } from "@/lib/format"; +import { locales, localeLabels, type Locale } from "@/lib/i18n/config"; +import { useTranslations } from "@/lib/i18n/context"; +import { describePasskeyError } from "@/lib/passkey-errors"; + +interface PasskeyInfo { + id: string; + name: string; + credentialDeviceType: string; + credentialBackedUp: boolean; + createdAt: string; +} + +export function AccountSection() { + const { t, locale, setLocale } = useTranslations(); + const { user, isLoading, isAuthenticated, refetch } = useAuth(); + const router = useRouter(); + + const [email, setEmail] = useState(""); + const [heightCm, setHeightCm] = useState(""); + const [dateOfBirth, setDateOfBirth] = useState(""); + const [gender, setGender] = useState(""); + const [saving, setSaving] = useState(false); + const [saveMsg, setSaveMsg] = useState(null); + const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>( + null, + ); + + // Password change dialog state. + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [passwordSaving, setPasswordSaving] = useState(false); + const [passwordMsg, setPasswordMsg] = useState(null); + const [passwordMsgType, setPasswordMsgType] = useState< + "success" | "error" | null + >(null); + const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); + + // Passkey registration state. + const [passkeyLoading, setPasskeyLoading] = useState(false); + const [passkeyMsg, setPasskeyMsg] = useState(null); + const [passkeyMsgType, setPasskeyMsgType] = useState< + "success" | "error" | null + >(null); + + // Auth gate — push back to login if the user is unauthenticated. Effect + // intentionally only navigates (never sets state in a way that re-runs the + // effect), so the lint rule is satisfied without a disable. + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push("/auth/login"); + } + }, [isLoading, isAuthenticated, router]); + + // Hydrate the form draft once the auth payload arrives. Using the + // React-recommended "store-the-prop-id alongside the state" pattern so the + // sync-from-server happens during render (not in a setState-in-effect), + // satisfying the strict `react-hooks/set-state-in-effect` rule. + const [seededUserId, setSeededUserId] = useState(null); + if (user && user.id !== seededUserId) { + setSeededUserId(user.id); + setEmail(user.email ?? ""); + setHeightCm(user.heightCm?.toString() ?? ""); + setDateOfBirth( + user.dateOfBirth + ? new Date(user.dateOfBirth).toISOString().slice(0, 10) + : "", + ); + setGender(user.gender ?? ""); + } + + async function handleSaveProfile(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setSaveMsg(null); + setSaveMsgType(null); + + const res = await fetch("/api/auth/profile", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: email.trim() || null, + heightCm: heightCm ? parseFloat(heightCm) : null, + dateOfBirth: dateOfBirth || null, + gender: gender || null, + }), + }); + + if (res.ok) { + setSaveMsg(t("settings.profileSaved")); + setSaveMsgType("success"); + await refetch(); + } else { + const json = await res.json(); + setSaveMsg(json.error || t("settings.savingError")); + setSaveMsgType("error"); + } + setSaving(false); + } + + async function handleAddPasskey() { + setPasskeyLoading(true); + setPasskeyMsg(null); + setPasskeyMsgType(null); + + try { + const optRes = await fetch("/api/auth/passkey/register-options", { + method: "POST", + }); + + if (!optRes.ok) { + setPasskeyMsg(t("settings.passkeyOptionsError")); + setPasskeyMsgType("error"); + setPasskeyLoading(false); + return; + } + + const optJson = await optRes.json(); + const { options, challengeId } = optJson.data; + + const { startRegistration } = await import("@simplewebauthn/browser"); + const credential = await startRegistration({ optionsJSON: options }); + + const verifyRes = await fetch("/api/auth/passkey/register-verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ challengeId, credential }), + }); + + if (verifyRes.ok) { + setPasskeyMsg(t("settings.passkeyAdded")); + setPasskeyMsgType("success"); + } else { + const verifyJson = await verifyRes.json(); + setPasskeyMsg( + verifyJson.error || t("settings.passkeyRegistrationFailed"), + ); + setPasskeyMsgType("error"); + } + } catch (err) { + const { key, params } = describePasskeyError(err); + setPasskeyMsg(t(key, params)); + setPasskeyMsgType("error"); + } finally { + setPasskeyLoading(false); + } + } + + async function handleChangePassword(e: React.FormEvent) { + e.preventDefault(); + setPasswordSaving(true); + setPasswordMsg(null); + setPasswordMsgType(null); + + if (newPassword !== confirmPassword) { + setPasswordMsg(t("settings.passwordMismatch")); + setPasswordMsgType("error"); + setPasswordSaving(false); + return; + } + + try { + const res = await fetch("/api/auth/password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + currentPassword, + newPassword, + confirmPassword, + }), + }); + const json = await res.json(); + + if (!res.ok) { + setPasswordMsg(json.error || t("settings.savingError")); + setPasswordMsgType("error"); + return; + } + + setPasswordMsg(t("settings.passwordUpdated")); + setPasswordMsgType("success"); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + } catch { + setPasswordMsg(t("common.networkError")); + setPasswordMsgType("error"); + } finally { + setPasswordSaving(false); + } + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!user) return null; + + return ( +
+
+

+ {t("settings.sections.account.title")} +

+

+ {t("settings.sections.account.description")} +

+
+ + {/* Profile card */} +
+
+ +

{t("settings.profile")}

+
+
+
+
+ + +
+
+ + setEmail(e.target.value)} + placeholder={t("auth.emailPlaceholder")} + maxLength={320} + /> +
+
+ +
+
+ + +

+ {t("settings.genderHint")} +

+
+
+ + setHeightCm(e.target.value)} + placeholder="175" + min={50} + max={300} + step={0.1} + /> +
+
+ +
+
+ + setDateOfBirth(e.target.value)} + max={new Date().toISOString().slice(0, 10)} + /> +

+ {t("settings.dateOfBirthHint")} +

+
+
+ + +

+ {t("settings.languageDescription")} +

+
+
+ + {saveMsg && ( +

+ {saveMsg} +

+ )} + +
+ +
+
+
+ + {/* Passkeys card */} +
+
+ +

{t("settings.passkeys")}

+
+ +
+ +
+ {passkeyMsg && ( +

+ {passkeyMsg} +

+ )} +
+ + {/* Password card */} +
+
+
+ +

+ {t("settings.passwordReset")} +

+
+ +
+

+ {t("settings.changePasswordDescription")} +

+
+ + { + setPasswordDialogOpen(open); + if (!open) { + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setPasswordMsg(null); + setPasswordMsgType(null); + } + }} + > + + + {t("settings.passwordReset")} + + {t("settings.changePasswordDescription")} + + + +
+
+
+ + setCurrentPassword(e.target.value)} + /> +
+
+ + setNewPassword(e.target.value)} + /> +
+
+ + setConfirmPassword(e.target.value)} + /> +
+
+ + {passwordMsg && ( +

+ {passwordMsg} +

+ )} + + +
+
+
+
+ ); +} + +function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [deleteMsg, setDeleteMsg] = useState(null); + + const { data: passkeys } = useQuery({ + queryKey: ["passkeys"], + queryFn: async () => { + const res = await fetch("/api/auth/passkeys"); + if (!res.ok) throw new Error("Failed"); + return (await res.json()).data as PasskeyInfo[]; + }, + enabled: isAuthenticated, + }); + + const deletePasskey = useMutation({ + mutationFn: async (id: string) => { + const res = await fetch(`/api/auth/passkeys/${id}`, { method: "DELETE" }); + const json = await res.json(); + if (!res.ok) throw new Error(json.error || t("common.error")); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["passkeys"] }); + setDeleteMsg(null); + }, + onError: (err: Error) => { + setDeleteMsg(err.message); + }, + }); + + const DEVICE_TYPE_LABELS: Record = { + singleDevice: t("settings.singleDevice"), + multiDevice: t("settings.multiDevice"), + }; + + if (!passkeys || passkeys.length === 0) { + return ( +
+

{t("settings.passkeys")}

+

+ {t("settings.noPasskeys")} +

+
+ ); + } + + return ( +
+

+ {t("settings.registeredPasskeys")} +

+

+ {t("settings.passkeysDescription")} +

+
+ + + + + + + + + + + + {passkeys.map((pk, idx) => ( + + + + + + + + ))} + +
+ {t("settings.passkeyName")} + + {t("settings.passkeyDevice")} + + {t("settings.passkeyBackup")} + + {t("settings.passkeyCreated")} + + {t("settings.passkeyActions")} +
{pk.name} + {DEVICE_TYPE_LABELS[pk.credentialDeviceType] ?? + pk.credentialDeviceType} + + + {pk.credentialBackedUp + ? t("settings.backedUp") + : t("common.no")} + + + {formatDate(pk.createdAt)} + + + + + + + + + {t("settings.deletePasskey")} + + + {t("settings.deletePasskeyDescription")} + + + + + {t("common.cancel")} + + deletePasskey.mutate(pk.id)} + > + {t("common.delete")} + + + + +
+
+ {deleteMsg && ( +
+ + {deleteMsg} +
+ )} +
+ ); +} diff --git a/src/components/settings/advanced-section.tsx b/src/components/settings/advanced-section.tsx new file mode 100644 index 000000000..145f0fa4c --- /dev/null +++ b/src/components/settings/advanced-section.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { AlertTriangle, Download, Loader2, Trash2 } from "lucide-react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { useTranslations } from "@/lib/i18n/context"; + +export function AdvancedSection() { + const { t } = useTranslations(); + + return ( +
+
+

+ {t("settings.sections.advanced.title")} +

+

+ {t("settings.sections.advanced.description")} +

+
+ + + +
+ ); +} + +function ExportCard() { + const { t, locale } = useTranslations(); + const [exporting, setExporting] = useState(false); + const [generatingReport, setGeneratingReport] = useState(false); + + async function handleExport(format: "csv" | "json") { + setExporting(true); + try { + const res = await fetch(`/api/export?format=${format}&type=all`); + if (!res.ok) return; + + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `healthlog-export-${new Date().toISOString().slice(0, 10)}.${format}`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } finally { + setExporting(false); + } + } + + async function handleDoctorReport() { + setGeneratingReport(true); + try { + const res = await fetch("/api/doctor-report", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ days: 90 }), + }); + if (!res.ok) return; + const json = await res.json(); + + const { generateDoctorReportPDF } = + await import("@/lib/doctor-report-pdf"); + const doc = generateDoctorReportPDF(json.data, { t, locale }); + const fileSlug = locale === "de" ? "gesundheitsbericht" : "health-report"; + doc.save(`${fileSlug}-${new Date().toISOString().slice(0, 10)}.pdf`); + } finally { + setGeneratingReport(false); + } + } + + return ( +
+
+ +

{t("settings.export")}

+
+

+ {t("settings.exportDescription")} +

+
+ + + +
+
+ ); +} + +function DataResetCard() { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [deleting, setDeleting] = useState(false); + const [msg, setMsg] = useState(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + + async function handleDeleteAllData() { + setDeleting(true); + setMsg(null); + setMsgType(null); + try { + const res = await fetch("/api/settings/data", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ confirm: "DELETE" }), + }); + const json = await res.json(); + if (!res.ok) { + setMsg(json.error || t("settings.dangerZoneDeleteFailed")); + setMsgType("error"); + return; + } + + await queryClient.invalidateQueries(); + setMsg(t("settings.dangerZoneSuccess")); + setMsgType("success"); + } catch { + setMsg(t("settings.dangerZoneDeleteFailed")); + setMsgType("error"); + } finally { + setDeleting(false); + } + } + + return ( +
+
+ +

+ {t("settings.dangerZone")} +

+
+

+ {t("settings.dangerZoneDescription")} +

+ +
+ + + + + + + + {t("settings.dangerZoneConfirm")} + + + {t("settings.dangerZoneConfirmDescription")} + + + + {t("common.cancel")} + + {t("settings.finalDelete")} + + + + +
+ + {msg && ( +

+ {msg} +

+ )} +
+ ); +} diff --git a/src/components/settings/ai-section.tsx b/src/components/settings/ai-section.tsx new file mode 100644 index 000000000..fcddd0bbe --- /dev/null +++ b/src/components/settings/ai-section.tsx @@ -0,0 +1,595 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2, RefreshCw, Save, Sparkles, Trash2 } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { PasswordInput } from "@/components/settings/password-input"; +import { useAuth } from "@/hooks/use-auth"; +import { formatDateTime } from "@/lib/format"; +import { useTranslations } from "@/lib/i18n/context"; + +interface InsightsSettings { + codexStatus: string; + codexConnectedAt: string | null; + hasAdminKey: boolean; + privacyMode: string; + lastInsightAt: string | null; +} + +interface UserAIProvider { + provider: string | null; + model: string | null; + baseUrl: string | null; + hasAnthropicKey: boolean; + anthropicKeyPreview: string | null; + hasLocalKey: boolean; +} + +export function AiSection() { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + + return ( +
+
+

+ {t("settings.sections.ai.title")} +

+

+ {t("settings.sections.ai.description")} +

+
+ + +
+ ); +} + +function InsightsSettingsCard({ + isAuthenticated, +}: { + isAuthenticated: boolean; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [disconnecting, setDisconnecting] = useState(false); + const [regenerating, setRegenerating] = useState(false); + const [msg, setMsg] = useState(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + + const { data: settings } = useQuery({ + queryKey: ["insights", "settings"], + queryFn: async () => { + const res = await fetch("/api/insights/settings"); + if (!res.ok) return null; + const json = await res.json(); + return json.data as InsightsSettings; + }, + enabled: isAuthenticated, + }); + + const updateSettings = useMutation({ + mutationFn: async (body: Record) => { + const res = await fetch("/api/insights/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const json = await res.json(); + if (!res.ok) throw new Error(json.error); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["insights"] }); + }, + }); + + // OAuth callback handler — reads `?codex_connected=true|codex_error=...` + // from the URL and surfaces a toast-equivalent inline message. We do the + // read at render time (via a `useState` lazy initialiser) so the strict + // `react-hooks/set-state-in-effect` rule isn't triggered. The effect then + // drains the result by clearing the URL params and invalidating queries. + const [oauthOutcome] = useState< + { kind: "connected" } | { kind: "error" } | null + >(() => { + if (typeof window === "undefined") return null; + const params = new URLSearchParams(window.location.search); + if (params.get("codex_connected") === "true") return { kind: "connected" }; + if (params.get("codex_error")) return { kind: "error" }; + return null; + }); + + useEffect(() => { + if (!oauthOutcome) return; + // The browser-history rewrite is the side effect; the matching i18n + // message is already painted via the lazy initialiser below. + window.history.replaceState({}, "", window.location.pathname); + if (oauthOutcome.kind === "connected") { + queryClient.invalidateQueries({ queryKey: ["insights"] }); + } + }, [oauthOutcome, queryClient]); + + // Seed the initial inline message from the OAuth outcome. Reads happen + // during render (allowed) — `setMsg` is only called by user actions after + // mount, never inside an effect. + const [oauthMsgSeeded, setOauthMsgSeeded] = useState(false); + if (!oauthMsgSeeded && oauthOutcome) { + setOauthMsgSeeded(true); + if (oauthOutcome.kind === "connected") { + setMsg(t("settings.codexConnected")); + setMsgType("success"); + } else { + setMsg(t("settings.codexConnectionFailed")); + setMsgType("error"); + } + } + + const hasProvider = + settings?.codexStatus === "connected" || settings?.hasAdminKey; + + function handleConnect() { + window.location.href = "/api/auth/codex/authorize"; + } + + async function handleDisconnect() { + setDisconnecting(true); + setMsg(null); + try { + const res = await fetch("/api/auth/codex/disconnect", { + method: "DELETE", + }); + if (!res.ok) { + const json = await res.json(); + throw new Error(json.error); + } + setMsg(t("settings.codexDisconnected")); + setMsgType("success"); + queryClient.invalidateQueries({ queryKey: ["insights"] }); + } catch (err) { + setMsg(err instanceof Error ? err.message : t("settings.savingError")); + setMsgType("error"); + } finally { + setDisconnecting(false); + } + } + + async function togglePrivacyMode() { + const newMode = settings?.privacyMode === "raw" ? "aggregated" : "raw"; + await updateSettings.mutateAsync({ privacyMode: newMode }); + } + + async function handleRegenerate() { + setRegenerating(true); + setMsg(null); + setMsgType(null); + try { + const res = await fetch("/api/insights/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ force: true }), + }); + const json = await res.json(); + if (!res.ok) { + if (res.status === 429) { + setMsg(t("settings.regenerateRateLimit")); + } else { + setMsg(json.error || t("settings.savingError")); + } + setMsgType("error"); + return; + } + setMsg(t("settings.regenerateSuccess")); + setMsgType("success"); + queryClient.invalidateQueries({ queryKey: ["insights"] }); + } catch { + setMsg(t("settings.savingError")); + setMsgType("error"); + } finally { + setRegenerating(false); + } + } + + return ( +
+
+
+ +

{t("settings.kiInsights")}

+
+
+ {settings?.codexStatus === "connected" && ( + + ChatGPT verbunden + + )} + {settings?.codexStatus !== "connected" && settings?.hasAdminKey && ( + + Admin-KI aktiv + + )} + {settings?.codexStatus === "expired" && ( + + Verbindung abgelaufen + + )} + {settings?.lastInsightAt && ( + + {t("settings.lastGeneratedAt")}:{" "} + {formatDateTime(settings.lastInsightAt)} + + )} +
+
+

+ {t("settings.kiInsightsDescription")} +

+ +
+
+ {settings?.codexStatus === "connected" ? ( +
+
+

ChatGPT verbunden

+

+ Insights werden über dein ChatGPT-Abo generiert — keine + zusätzlichen Kosten. + {settings.codexConnectedAt && ( + <> + {" "} + Verbunden seit {formatDateTime(settings.codexConnectedAt)} + . + + )} +

+
+ +
+ ) : ( +
+
+

Mit ChatGPT verbinden

+

+ Verbinde dein ChatGPT Pro/Max-Konto um KI-gestützte + Gesundheitsanalysen basierend auf aktuellen medizinischen + Leitlinien zu erhalten. Keine zusätzlichen API-Kosten. +

+
+ + {settings?.hasAdminKey && ( +

+ Alternativ nutzt HealthLog den vom Administrator + konfigurierten KI-Anbieter. +

+ )} +
+ )} +
+ + {msg && ( +

+ {msg} +

+ )} + + {hasProvider && ( +
+
+
+

{t("settings.rawData")}

+

+ {settings?.privacyMode === "raw" + ? t("settings.rawDataOnDescription") + : t("settings.rawDataOffDescription")} +

+
+
+ +
+
+ {settings?.privacyMode === "raw" && ( +
+ {t("settings.rawDataWarning")} +
+ )} +
+ )} + + {hasProvider && ( + + )} + + +
+
+ ); +} + +function UserAIProviderSubsection() { + const queryClient = useQueryClient(); + const [provider, setProvider] = useState(""); + const [model, setModel] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); + const [anthropicKey, setAnthropicKey] = useState(""); + const [localKey, setLocalKey] = useState(""); + const [saveMsg, setSaveMsg] = useState(null); + const [saveOk, setSaveOk] = useState(false); + const [testMsg, setTestMsg] = useState(null); + const [testOk, setTestOk] = useState(false); + const [testing, setTesting] = useState(false); + + const { data, isLoading } = useQuery({ + queryKey: ["user", "ai-provider"], + queryFn: async () => { + const res = await fetch("/api/user/ai-provider"); + if (!res.ok) return null; + const json = await res.json(); + return json.data as UserAIProvider; + }, + }); + + // React-recommended sync-from-server pattern (no setState-in-effect). + const dataKey = data + ? `${data.provider ?? ""}|${data.model ?? ""}|${data.baseUrl ?? ""}` + : null; + const [seededKey, setSeededKey] = useState(null); + if (dataKey && dataKey !== seededKey) { + setSeededKey(dataKey); + setProvider(data!.provider ?? ""); + setModel(data!.model ?? ""); + setBaseUrl(data!.baseUrl ?? ""); + } + + const saveMutation = useMutation({ + mutationFn: async () => { + const body: Record = { + provider: provider || null, + model: model || null, + baseUrl: baseUrl || null, + }; + if (anthropicKey.trim()) body.anthropicKey = anthropicKey.trim(); + if (localKey.trim()) body.localKey = localKey.trim(); + const res = await fetch("/api/user/ai-provider", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const json = await res.json(); + if (!res.ok) throw new Error(json.error || "Save failed"); + }, + onSuccess: () => { + setSaveMsg("Gespeichert"); + setSaveOk(true); + setAnthropicKey(""); + setLocalKey(""); + queryClient.invalidateQueries({ queryKey: ["user", "ai-provider"] }); + queryClient.invalidateQueries({ queryKey: ["insights"] }); + }, + onError: (e) => { + setSaveMsg(e instanceof Error ? e.message : "Fehler"); + setSaveOk(false); + }, + }); + + async function runTest() { + setTesting(true); + setTestMsg(null); + try { + const overrideBody: Record = {}; + if (provider) overrideBody.provider = provider; + if (model.trim()) overrideBody.model = model.trim(); + if (baseUrl.trim()) overrideBody.baseUrl = baseUrl.trim(); + if (anthropicKey.trim()) overrideBody.anthropicKey = anthropicKey.trim(); + if (localKey.trim()) overrideBody.localKey = localKey.trim(); + const hasOverride = Object.keys(overrideBody).length > 0; + const res = await fetch("/api/ai/test", { + method: "POST", + headers: hasOverride ? { "Content-Type": "application/json" } : {}, + body: hasOverride ? JSON.stringify(overrideBody) : undefined, + }); + const json = await res.json(); + if (!res.ok) { + setTestMsg(json.error || `HTTP ${res.status}`); + setTestOk(false); + return; + } + setTestMsg( + `OK — ${json.data.providerType} (${json.data.model})${ + json.data.tokensUsed ? `, ${json.data.tokensUsed} tokens` : "" + }`, + ); + setTestOk(true); + } catch (e) { + setTestMsg(e instanceof Error ? e.message : "Test fehlgeschlagen"); + setTestOk(false); + } finally { + setTesting(false); + } + } + + if (isLoading) return null; + + return ( +
+
+

KI-Provider (persönlich)

+

+ Eigener KI-Anbieter überschreibt die Admin-Einstellung. Leer lassen + für Standard. +

+
+ +
+
+ + +
+ +
+ + setModel(e.target.value)} + placeholder={ + provider === "ANTHROPIC" + ? "claude-3-5-sonnet-latest" + : provider === "LOCAL" + ? "llama3:8b" + : "gpt-4o-mini" + } + className="mt-1" + /> +
+ + {provider === "ANTHROPIC" && ( +
+ + setAnthropicKey(e.target.value)} + placeholder="sk-ant-..." + className="mt-1" + /> +
+ )} + + {provider === "LOCAL" && ( + <> +
+ + setBaseUrl(e.target.value)} + placeholder="http://localhost:11434/v1" + className="mt-1" + /> +
+
+ + setLocalKey(e.target.value)} + placeholder={data?.hasLocalKey ? "(gespeichert)" : ""} + className="mt-1" + /> +
+ + )} +
+ +
+ + +
+ + {saveMsg && ( +

+ {saveMsg} +

+ )} + {testMsg && ( +

+ {testMsg} +

+ )} +
+ ); +} diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx new file mode 100644 index 000000000..5bc00c94a --- /dev/null +++ b/src/components/settings/api-section.tsx @@ -0,0 +1,409 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { ChevronDown, Key, Loader2, Trash2 } from "lucide-react"; + +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 { useAuth } from "@/hooks/use-auth"; +import { formatDate, formatDateTime } from "@/lib/format"; +import { useTranslations } from "@/lib/i18n/context"; + +interface ApiTokenInfo { + id: string; + name: string; + permissions: string[]; + lastUsedAt: string | null; + expiresAt: string | null; + createdAt: string; + revoked: boolean; +} + +export function ApiSection() { + const { t } = useTranslations(); + + return ( +
+
+

+ {t("settings.sections.api.title")} +

+

+ {t("settings.sections.api.description")} +

+
+ + + +
+ ); +} + +function ApiEndpointsCard() { + const { t } = useTranslations(); + + return ( +
+
+ +

+ {t("settings.apiEndpointsTitle")} +

+
+

+ {t("settings.apiEndpointsDescription")} +

+ +
+ + + + + + + + + + + + + + + + + +
+ {t("settings.apiEndpointMethod")} + + {t("settings.apiEndpointPath")} + + {t("settings.apiEndpointAuth")} + + {t("settings.apiEndpointExample")} +
POST/api/ingest/medication + Authorization: Bearer hlk_... + + {`{ "medicationName": "...", "scheduledFor": "..." }`} +
+
+
+ ); +} + +function ApiTokensCard() { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + const queryClient = useQueryClient(); + const [newName, setNewName] = useState(""); + const [newToken, setNewToken] = useState(null); + const [creating, setCreating] = useState(false); + const [tokenMsg, setTokenMsg] = useState(null); + const [showRevokedTokens, setShowRevokedTokens] = useState(false); + + const { data: tokens } = useQuery({ + queryKey: ["tokens"], + queryFn: async () => { + const res = await fetch("/api/tokens"); + if (!res.ok) throw new Error("Failed"); + return (await res.json()).data as ApiTokenInfo[]; + }, + enabled: isAuthenticated, + }); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + setCreating(true); + setTokenMsg(null); + setNewToken(null); + + try { + const res = await fetch("/api/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: ["tokens"] }); + } else { + setTokenMsg(json.error || t("common.error")); + } + } catch { + setTokenMsg(t("common.networkError")); + } finally { + setCreating(false); + } + } + + async function handleRevoke(tokenId: string) { + const res = await fetch(`/api/tokens/${tokenId}`, { method: "DELETE" }); + if (res.ok) { + queryClient.invalidateQueries({ queryKey: ["tokens"] }); + } + } + + const activeTokens = (tokens ?? []).filter((tok) => !tok.revoked); + const revokedTokens = (tokens ?? []).filter((tok) => tok.revoked); + const latestActiveUse = activeTokens.reduce((latest, tok) => { + if (!tok.lastUsedAt) return latest; + if (!latest || new Date(tok.lastUsedAt) > new Date(latest)) { + return tok.lastUsedAt; + } + return latest; + }, null); + + return ( +
+
+
+ +

{t("settings.apiTokens")}

+
+
+ {activeTokens.length > 0 && ( + + {t("settings.configured")} + + )} + {latestActiveUse && ( + + {t("settings.tokenTableLastUsed")}:{" "} + {formatDateTime(latestActiveUse)} + + )} +
+
+

+ {t("settings.apiTokensDescription")} +

+ +
+
+ setNewName(e.target.value)} + placeholder={t("settings.tokenNamePlaceholder")} + maxLength={100} + className="flex-1" + /> + +
+ + {newToken && ( +
+

+ {t("settings.tokenCreated")} +

+ + {newToken} + +
+ )} + + {tokenMsg && ( +

+ {tokenMsg} +

+ )} + +
+

+ {t("settings.activeTokensTitle")} +

+
+ + + + + + + + + + + + + {activeTokens.length === 0 && ( + + + + )} + {activeTokens.map((tok, index) => { + const isExpired = + tok.expiresAt && new Date(tok.expiresAt) < new Date(); + return ( + + + + + + + + + ); + })} + +
+ {t("settings.tokenTableName")} + + {t("settings.tokenTablePermissions")} + + {t("settings.tokenTableStatus")} + + {t("settings.tokenTableCreated")} + + {t("settings.tokenTableLastUsed")} + + {t("settings.tokenTableActions")} +
+ {t("settings.noActiveTokens")} +
{tok.name} + {tok.permissions.join(", ")} + + {isExpired ? ( + + {t("settings.tokenExpired")} + + ) : ( + + {t("settings.tokenActive")} + + )} + + {formatDate(tok.createdAt)} + + {tok.lastUsedAt + ? formatDateTime(tok.lastUsedAt) + : t("settings.tokenNeverUsed")} + + + + + + + + + {t("settings.tokenRevoke")} + + + {t("settings.tokenRevokeDescription")} + + + + + {t("common.cancel")} + + handleRevoke(tok.id)} + > + {t("settings.tokenRevoked")} + + + + +
+
+
+ + {revokedTokens.length > 0 && ( +
+ + {showRevokedTokens && ( +
+ + + + + + + + + + + {revokedTokens.map((tok, index) => ( + + + + + + + ))} + +
+ {t("settings.tokenTableName")} + + {t("settings.tokenTablePermissions")} + + {t("settings.tokenTableCreated")} + + {t("settings.tokenTableLastUsed")} +
{tok.name} + {tok.permissions.join(", ")} + + {formatDate(tok.createdAt)} + + {tok.lastUsedAt + ? formatDateTime(tok.lastUsedAt) + : t("settings.tokenNeverUsed")} +
+
+ )} +
+ )} +
+
+ ); +} diff --git a/src/components/settings/dashboard-section.tsx b/src/components/settings/dashboard-section.tsx new file mode 100644 index 000000000..90a8b1728 --- /dev/null +++ b/src/components/settings/dashboard-section.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { DashboardLayoutSection } from "@/components/settings/dashboard-layout-section"; +import { ThresholdsSection } from "@/components/settings/thresholds-section"; +import { useTranslations } from "@/lib/i18n/context"; + +export function DashboardSection() { + const { t } = useTranslations(); + + return ( +
+
+

+ {t("settings.sections.dashboard.title")} +

+

+ {t("settings.sections.dashboard.description")} +

+
+ + + +
+ ); +} diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx new file mode 100644 index 000000000..19df20b73 --- /dev/null +++ b/src/components/settings/integrations-section.tsx @@ -0,0 +1,683 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Download, + Link2, + Loader2, + RefreshCw, + Save, + Smile, + Unlink, +} from "lucide-react"; + +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 { Label } from "@/components/ui/label"; +import { PasswordInput } from "@/components/settings/password-input"; +import { TestConnectionButton } from "@/components/settings/test-connection-button"; +import { useAuth } from "@/hooks/use-auth"; +import { formatDateTime } from "@/lib/format"; +import { useTranslations } from "@/lib/i18n/context"; +import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys"; + +interface MoodLogStatus { + configured: boolean; + enabled: boolean; + lastSyncedAt: string | null; + entryCount: number; + webhookSecret: string | null; +} + +interface GlobalServiceAvailability { + telegramGlobal: boolean; + ntfyGlobal: boolean; + webPushGlobal: boolean; + apiGlobal: boolean; + moodLogGlobal: boolean; +} + +export function IntegrationsSection() { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + + const { data: globalServices } = useQuery({ + queryKey: ["settings", "global-services"], + queryFn: async () => { + const res = await fetch("/api/settings/global-services"); + if (!res.ok) throw new Error("Failed"); + return (await res.json()).data as GlobalServiceAvailability; + }, + enabled: isAuthenticated, + }); + + const moodLogEnabled = globalServices?.moodLogGlobal ?? true; + + return ( +
+
+

+ {t("settings.sections.integrations.title")} +

+

+ {t("settings.sections.integrations.description")} +

+
+ + + {moodLogEnabled && } +
+ ); +} + +function WithingsCard({ isAuthenticated }: { isAuthenticated: boolean }) { + const { t } = useTranslations(); + const [syncing, setSyncing] = useState(false); + const [syncMsg, setSyncMsg] = useState(null); + const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>( + null, + ); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [credsSaving, setCredsSaving] = useState(false); + const [credsMsg, setCredsMsg] = useState(null); + const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>( + null, + ); + const queryClient = useQueryClient(); + + const { data: status } = useQuery({ + queryKey: ["withings", "status"], + queryFn: async () => { + const res = await fetch("/api/withings/status"); + if (!res.ok) throw new Error("Failed"); + const json = await res.json(); + return json.data as { + connected: boolean; + configured: boolean; + lastSyncedAt?: string | null; + connectedAt?: string; + tokenExpired?: boolean; + }; + }, + enabled: isAuthenticated, + }); + + const disconnect = useMutation({ + mutationFn: async () => { + const res = await fetch("/api/withings/disconnect", { method: "POST" }); + if (!res.ok) throw new Error("Failed"); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["withings"] }); + }, + }); + + async function handleSync(fullSync = false) { + setSyncing(true); + setSyncMsg(null); + setSyncMsgType(null); + try { + const res = await fetch("/api/withings/sync", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fullSync }), + }); + const json = await res.json(); + if (res.ok) { + setSyncMsg( + fullSync + ? t("settings.withingsFullSyncResult", { + count: json.data.imported, + }) + : t("settings.withingsSyncResult", { count: json.data.imported }), + ); + setSyncMsgType("success"); + void invalidateKeys(queryClient, measurementDependentKeys); + } else { + setSyncMsg(json.error || t("settings.withingsSyncFailed")); + setSyncMsgType("error"); + } + } catch { + setSyncMsg(t("settings.withingsSyncFailed")); + setSyncMsgType("error"); + } finally { + setSyncing(false); + } + } + + async function handleSaveCredentials(e: React.FormEvent) { + e.preventDefault(); + setCredsSaving(true); + setCredsMsg(null); + setCredsMsgType(null); + + try { + const res = await fetch("/api/withings/credentials", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + }), + }); + + if (res.ok) { + setCredsMsg(t("settings.withingsCredentialsSaved")); + setCredsMsgType("success"); + setClientId(""); + setClientSecret(""); + queryClient.invalidateQueries({ queryKey: ["withings"] }); + } else { + try { + const json = await res.json(); + setCredsMsg(json.error || t("settings.savingError")); + } catch { + setCredsMsg(t("settings.savingError")); + } + setCredsMsgType("error"); + } + } catch { + setCredsMsg(t("common.networkError")); + setCredsMsgType("error"); + } + setCredsSaving(false); + } + + return ( +
+
+
+ +

{t("settings.withings")}

+
+
+ {status?.configured && !status?.connected && ( + + {t("settings.configured")} + + )} + {status?.connected && ( + + {t("settings.withingsConnected")} + + )} + {status?.lastSyncedAt && ( + + {t("settings.withingsLastSync")}:{" "} + {formatDateTime(status.lastSyncedAt)} + + )} + {status?.tokenExpired && ( + + {t("settings.withingsTokenExpired")} + + )} +
+
+

+ {t("settings.withingsDescription")} +

+ +
+
+

+ {t("settings.withingsCredentials")} +

+
+
+
+ + setClientId(e.target.value)} + placeholder={ + status?.configured + ? t("settings.withingsCredentialsSavedPlaceholder") + : t("settings.withingsClientId") + } + maxLength={200} + /> +
+
+ + setClientSecret(e.target.value)} + placeholder={ + status?.configured + ? t("settings.withingsCredentialsSavedPlaceholderSecret") + : t("settings.withingsClientSecret") + } + maxLength={200} + /> +
+
+ + +
+
+ {credsMsg && ( +

+ {credsMsg} +

+ )} +
+
+ + {status?.connected ? ( + <> +
+ + + + + + + + + {t("settings.withingsFullSyncTitle")} + + + {t("settings.withingsFullSyncDescription")} + + + + {t("common.cancel")} + handleSync(true)}> + {t("settings.withingsSynchronize")} + + + + + + + + + + + + + {t("settings.withingsDisconnectTitle")} + + + {t("settings.withingsDisconnectDescription")} + + + + {t("common.cancel")} + disconnect.mutate()} + > + {t("settings.withingsDisconnect")} + + + + +
+ {syncMsg && ( +

+ {syncMsg} +

+ )} + + ) : status?.configured ? ( + + ) : ( +
+ {t("settings.withingsNoCredentials")} +
+ )} +
+
+ ); +} + +function MoodLogCard() { + const { t } = useTranslations(); + const [url, setUrl] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [saving, setSaving] = useState(false); + const [syncing, setSyncing] = useState(false); + const [msg, setMsg] = useState(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + + const { data: status, refetch: refetchStatus } = useQuery({ + queryKey: ["moodlog-status"], + queryFn: async () => { + const res = await fetch("/api/integrations/moodlog/status"); + if (!res.ok) throw new Error("Failed"); + return (await res.json()).data as MoodLogStatus; + }, + }); + + async function handleSave(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setMsg(null); + const res = await fetch("/api/settings/moodlog", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: url.trim(), apiKey: apiKey.trim() }), + }); + if (res.ok) { + setMsg(t("settings.moodLogSaved")); + setMsgType("success"); + setUrl(""); + setApiKey(""); + await refetchStatus(); + } else { + const json = await res.json(); + setMsg(json.error || t("settings.savingError")); + setMsgType("error"); + } + setSaving(false); + } + + async function handleSync(fullSync = false) { + setSyncing(true); + setMsg(null); + try { + const res = await fetch("/api/integrations/moodlog/sync", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fullSync }), + }); + if (res.ok) { + const json = await res.json(); + setMsg( + t("settings.moodLogSyncResult").replace( + "{count}", + String(json.data.imported), + ), + ); + setMsgType("success"); + await refetchStatus(); + } else { + setMsg(t("settings.moodLogSyncFailed")); + setMsgType("error"); + } + } catch { + setMsg(t("settings.moodLogSyncFailed")); + setMsgType("error"); + } + setSyncing(false); + } + + async function handleDisconnect() { + const res = await fetch("/api/settings/moodlog", { method: "DELETE" }); + if (res.ok) { + setMsg(t("settings.moodLogDisconnected")); + setMsgType("success"); + await refetchStatus(); + } + } + + return ( +
+
+ + {status?.configured && ( + + {status.enabled + ? t("settings.withingsConnected") + : t("settings.configured")} + + )} +
+

+ {t("settings.moodLogDescription")} +

+ +
+
+ + setUrl(e.target.value)} + /> +
+
+ + setApiKey(e.target.value)} + /> +
+
+ + +
+
+ + {status?.configured && ( +
+ {status.webhookSecret && ( +
+ +
+ + +
+

+ {t("settings.moodLogWebhookSecretHelp")} +

+
+ )} + +
+ {status.lastSyncedAt && ( + + {t("settings.moodLogLastSync")}:{" "} + {formatDateTime(status.lastSyncedAt)} + + )} + + {t("settings.moodLogEntries")}: {status.entryCount} + +
+ +
+ + + + + + + + + + {t("settings.moodLogFullSyncTitle")} + + + {t("settings.moodLogFullSyncDescription")} + + + + {t("common.cancel")} + handleSync(true)}> + {t("settings.moodLogFullSync")} + + + + + + + + + + + + + {t("settings.moodLogDisconnectTitle")} + + + {t("settings.moodLogDisconnectDescription")} + + + + {t("common.cancel")} + + {t("settings.moodLogDisconnect")} + + + + +
+
+ )} + + {msg && ( +

+ {msg} +

+ )} +
+ ); +} diff --git a/src/components/settings/notifications-section.tsx b/src/components/settings/notifications-section.tsx new file mode 100644 index 000000000..3fc3ee2d2 --- /dev/null +++ b/src/components/settings/notifications-section.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import { NtfyCard } from "@/components/settings/ntfy-card"; +import { TelegramCard } from "@/components/settings/telegram-card"; +import { WebPushCard } from "@/components/settings/web-push-card"; + +interface GlobalServiceAvailability { + telegramGlobal: boolean; + ntfyGlobal: boolean; + webPushGlobal: boolean; + apiGlobal: boolean; + moodLogGlobal: boolean; +} + +export function NotificationsSection() { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + + const { data: globalServices } = useQuery({ + queryKey: ["settings", "global-services"], + queryFn: async () => { + const res = await fetch("/api/settings/global-services"); + if (!res.ok) throw new Error("Failed"); + return (await res.json()).data as GlobalServiceAvailability; + }, + enabled: isAuthenticated, + }); + + const showTelegram = globalServices?.telegramGlobal ?? true; + const showNtfy = globalServices?.ntfyGlobal ?? true; + const showWebPush = globalServices?.webPushGlobal ?? true; + + return ( +
+
+

+ {t("settings.sections.notifications.title")} +

+

+ {t("settings.sections.notifications.description")} +

+
+ + {showTelegram && } + {showNtfy && } + {showWebPush && } +
+ ); +} diff --git a/src/components/settings/ntfy-card.tsx b/src/components/settings/ntfy-card.tsx new file mode 100644 index 000000000..ae98d963c --- /dev/null +++ b/src/components/settings/ntfy-card.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Bell, Loader2, Send } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { PasswordInput } from "@/components/settings/password-input"; +import { useTranslations } from "@/lib/i18n/context"; + +interface NtfySettings { + enabled: boolean; + serverUrl: string; + topic: string; + hasAuthToken: boolean; +} + +export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [serverUrl, setServerUrl] = useState("https://ntfy.sh"); + const [topic, setTopic] = useState(""); + const [authToken, setAuthToken] = useState(""); + const [saveMsg, setSaveMsg] = useState(null); + const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>( + null, + ); + + const { data: settings } = useQuery({ + queryKey: ["settings", "ntfy"], + queryFn: async () => { + const res = await fetch("/api/settings/ntfy"); + if (!res.ok) throw new Error("Failed"); + const json = await res.json(); + return json.data as NtfySettings; + }, + enabled: isAuthenticated, + }); + + // React-recommended sync-from-server pattern (no setState-in-effect). + const settingsKey = settings + ? `${settings.serverUrl}|${settings.topic}` + : null; + const [seededKey, setSeededKey] = useState(null); + if (settingsKey && settingsKey !== seededKey) { + setSeededKey(settingsKey); + setServerUrl(settings!.serverUrl); + setTopic(settings!.topic); + } + + const save = useMutation({ + mutationFn: async (enabled: boolean) => { + const res = await fetch("/api/settings/ntfy", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + serverUrl, + topic, + authToken: authToken || undefined, + enabled, + }), + }); + if (!res.ok) { + const json = await res.json(); + throw new Error(json.error || t("common.error")); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["settings", "ntfy"] }); + setSaveMsg(t("settings.saved")); + setSaveMsgType("success"); + setAuthToken(""); + }, + onError: (err: Error) => { + setSaveMsg(err.message); + setSaveMsgType("error"); + }, + }); + + const test = useMutation({ + mutationFn: async () => { + const res = await fetch("/api/settings/ntfy/test", { method: "POST" }); + if (!res.ok) { + const json = await res.json(); + throw new Error(json.error || t("common.error")); + } + }, + onSuccess: () => { + setSaveMsg(t("settings.testSent")); + setSaveMsgType("success"); + }, + onError: (err: Error) => { + setSaveMsg(err.message); + setSaveMsgType("error"); + }, + }); + + return ( +
+
+
+ +

{t("settings.ntfy")}

+
+
+ {settings?.serverUrl && settings?.topic && ( + + {t("settings.configured")} + + )} + {settings?.enabled && ( + + {t("common.enabled")} + + )} +
+
+

+ {t("settings.ntfyDescription")} +

+ +
+
+ + save.mutate(checked)} + disabled={save.isPending} + /> +
+ +
{ + e.preventDefault(); + save.mutate(settings?.enabled ?? false); + }} + className="space-y-4" + > +
+
+ + setServerUrl(e.target.value)} + /> +
+
+ + setTopic(e.target.value)} + /> +
+
+ + setAuthToken(e.target.value)} + /> +

+ {t("settings.ntfyAuthTokenHint")} +

+
+
+ + {saveMsg && ( +

+ {saveMsg} +

+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/src/components/settings/password-input.tsx b/src/components/settings/password-input.tsx new file mode 100644 index 000000000..d4f915afb --- /dev/null +++ b/src/components/settings/password-input.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useState } from "react"; +import { Eye, EyeOff } from "lucide-react"; + +import { Input } from "@/components/ui/input"; + +/** + * Password input with a built-in show/hide toggle. Extracted from the legacy + * settings monolith so every section that needs a masked secret (Withings, + * moodLog, ntfy, Telegram, AI provider keys, account password) can share the + * same UX without duplicating the visibility-toggle plumbing. + */ +export function PasswordInput(props: React.ComponentProps) { + const [visible, setVisible] = useState(false); + return ( +
+ + +
+ ); +} diff --git a/src/components/settings/telegram-card.tsx b/src/components/settings/telegram-card.tsx new file mode 100644 index 000000000..1f7eb5bbd --- /dev/null +++ b/src/components/settings/telegram-card.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2, MessageCircle, Save, Send } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { PasswordInput } from "@/components/settings/password-input"; +import { useTranslations } from "@/lib/i18n/context"; + +interface TelegramSettings { + enabled: boolean; + hasBotToken: boolean; + chatId: string | null; +} + +export function TelegramCard({ + isAuthenticated, +}: { + isAuthenticated: boolean; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [botToken, setBotToken] = useState(""); + const [chatId, setChatId] = useState(""); + const [enabled, setEnabled] = useState(false); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [msg, setMsg] = useState(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + + const { data: settings } = useQuery({ + queryKey: ["telegram", "settings"], + queryFn: async () => { + const res = await fetch("/api/settings/telegram"); + if (!res.ok) throw new Error("Failed"); + const json = await res.json(); + return json.data as TelegramSettings; + }, + enabled: isAuthenticated, + }); + + // Sync from server using the React-recommended "previous-payload-id" + // pattern instead of setState-in-effect. This avoids the strict + // `react-hooks/set-state-in-effect` lint failure that the legacy file + // disabled at module scope. + const settingsKey = settings + ? `${settings.enabled}|${settings.chatId ?? ""}` + : null; + const [seededKey, setSeededKey] = useState(null); + if (settingsKey && settingsKey !== seededKey) { + setSeededKey(settingsKey); + setEnabled(settings!.enabled); + if (settings!.chatId) setChatId(settings!.chatId); + } + + async function handleSave(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setMsg(null); + setMsgType(null); + + const body: Record = { enabled }; + if (botToken.trim()) body.botToken = botToken.trim(); + if (chatId !== (settings?.chatId ?? "")) body.chatId = chatId; + + const res = await fetch("/api/settings/telegram", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (res.ok) { + setMsg(t("settings.telegramSaved")); + setMsgType("success"); + setBotToken(""); + queryClient.invalidateQueries({ queryKey: ["telegram"] }); + } else { + const json = await res.json(); + setMsg(json.error || t("settings.savingError")); + setMsgType("error"); + } + setSaving(false); + } + + async function handleTest() { + setTesting(true); + setMsg(null); + setMsgType(null); + + const res = await fetch("/api/settings/telegram/test", { method: "POST" }); + if (res.ok) { + setMsg(t("settings.testSent")); + setMsgType("success"); + } else { + const json = await res.json(); + setMsg(json.error || t("common.error")); + setMsgType("error"); + } + setTesting(false); + } + + return ( +
+
+
+ +

{t("settings.telegram")}

+
+
+ {settings?.hasBotToken && settings?.chatId && ( + + {t("settings.configured")} + + )} + {settings?.enabled && ( + + {t("common.enabled")} + + )} +
+
+

+ {t("settings.telegramDescription")} +

+ +
+
+
+
+ + setBotToken(e.target.value)} + placeholder={ + settings?.hasBotToken + ? t("settings.withingsCredentialsSavedPlaceholder") + : "123456:ABC-DEF..." + } + maxLength={100} + /> +
+
+ + setChatId(e.target.value)} + placeholder="123456789" + maxLength={50} + /> +
+
+ +
+ + +
+ +
+

{t("settings.telegramStep1")}

+

{t("settings.telegramStep2")}

+

{t("settings.telegramStep3")}

+
+ + {msg && ( +

+ {msg} +

+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/src/components/settings/test-connection-button.tsx b/src/components/settings/test-connection-button.tsx new file mode 100644 index 000000000..0909b5723 --- /dev/null +++ b/src/components/settings/test-connection-button.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useState } from "react"; +import { CheckCircle2, Loader2, Plug, XCircle } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * Shared "Test connection" UX for the settings integrations + notifications + * sections (A8-UI). POSTs to the given endpoint, expects a `{ data, error, + * meta }` envelope, and surfaces success (latency) or a translated + * `meta.errorCode` callout. + * + * Endpoints are user-scoped, so the button intentionally does NOT send an + * Idempotency-Key — each click probes the upstream live. + */ +export interface TestConnectionButtonProps { + endpoint: string; + /** Optional disabled flag from the parent — typically "no credentials yet". */ + disabled?: boolean; + /** Label override for the button (defaults to settings.testConnection.test). */ + label?: string; +} + +interface TestResponse { + data?: { latencyMs?: number; ok?: boolean; sent?: number }; + error?: string; + meta?: { errorCode?: string }; +} + +export function TestConnectionButton({ + endpoint, + disabled = false, + label, +}: TestConnectionButtonProps) { + const { t } = useTranslations(); + const [testing, setTesting] = useState(false); + const [result, setResult] = useState< + | { kind: "ok"; latency: number } + | { kind: "error"; errorCode: string } + | null + >(null); + + async function handleClick() { + setTesting(true); + setResult(null); + try { + const res = await fetch(endpoint, { method: "POST" }); + const json = (await res.json().catch(() => ({}))) as TestResponse; + + if (res.ok && json.data?.ok !== false) { + setResult({ + kind: "ok", + latency: json.data?.latencyMs ?? 0, + }); + } else { + setResult({ + kind: "error", + errorCode: json.meta?.errorCode ?? "generic", + }); + } + } catch { + setResult({ kind: "error", errorCode: "connection_failed" }); + } finally { + setTesting(false); + } + } + + function describeError(errorCode: string): string { + const key = `settings.testConnection.errors.${errorCode}`; + const translated = t(key); + // `t()` returns the raw key when missing — fall back to generic. + if (translated === key) { + return t("settings.testConnection.errors.generic"); + } + return translated; + } + + return ( +
+ + + {result?.kind === "ok" && ( +

+ + {t("settings.testConnection.ok", { latency: result.latency })} +

+ )} + + {result?.kind === "error" && ( +

+ + {describeError(result.errorCode)} +

+ )} +
+ ); +} diff --git a/src/components/settings/web-push-card.tsx b/src/components/settings/web-push-card.tsx new file mode 100644 index 000000000..63e25c9ce --- /dev/null +++ b/src/components/settings/web-push-card.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { BellRing, Loader2 } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { TestConnectionButton } from "@/components/settings/test-connection-button"; +import { useTranslations } from "@/lib/i18n/context"; + +/** Convert a URL-safe base64 string to an ArrayBuffer (for VAPID key). */ +function urlBase64ToArrayBuffer(base64String: string): ArrayBuffer { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + const raw = atob(base64); + const buffer = new ArrayBuffer(raw.length); + const view = new Uint8Array(buffer); + for (let i = 0; i < raw.length; ++i) { + view[i] = raw.charCodeAt(i); + } + return buffer; +} + +export function WebPushCard() { + const { t } = useTranslations(); + const [isSubscribed, setIsSubscribed] = useState(false); + const [isSupported, setIsSupported] = useState(true); + const [isDenied, setIsDenied] = useState(false); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(false); + const [msg, setMsg] = useState(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + + useEffect(() => { + // Async I/O (browser API + service worker registration). The lint rule + // allows setState inside async callbacks — only synchronous setState in + // the effect body is rejected. + async function checkSubscription() { + if (!("serviceWorker" in navigator) || !("PushManager" in window)) { + setIsSupported(false); + setLoading(false); + return; + } + + if (Notification.permission === "denied") { + setIsDenied(true); + setLoading(false); + return; + } + + try { + const registration = + await navigator.serviceWorker.getRegistration("/sw.js"); + if (registration) { + const subscription = await registration.pushManager.getSubscription(); + setIsSubscribed(!!subscription); + } + } catch { + // Ignore errors during check + } + setLoading(false); + } + + void checkSubscription(); + }, []); + + async function handleSubscribe() { + setActionLoading(true); + setMsg(null); + setMsgType(null); + + try { + const vapidRes = await fetch("/api/notifications/vapid"); + if (!vapidRes.ok) { + setMsg(t("settings.webPushNotConfigured")); + setMsgType("error"); + setActionLoading(false); + return; + } + const vapidData = await vapidRes.json(); + const vapidPublicKey = vapidData.data.publicKey; + + const registration = await navigator.serviceWorker.register("/sw.js"); + await navigator.serviceWorker.ready; + + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToArrayBuffer(vapidPublicKey), + }); + + const subJson = subscription.toJSON(); + + const res = await fetch("/api/notifications/web-push", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + endpoint: subJson.endpoint, + keys: { + p256dh: subJson.keys?.p256dh ?? "", + auth: subJson.keys?.auth ?? "", + }, + }), + }); + + if (res.ok) { + setIsSubscribed(true); + setMsg(t("settings.webPushSubscribed")); + setMsgType("success"); + } else { + setMsg(t("settings.webPushSubscribeFailed")); + setMsgType("error"); + } + } catch { + if (Notification.permission === "denied") { + setIsDenied(true); + setMsg(t("settings.webPushDenied")); + } else { + setMsg(t("settings.webPushSubscribeFailed")); + } + setMsgType("error"); + } finally { + setActionLoading(false); + } + } + + async function handleUnsubscribe() { + setActionLoading(true); + setMsg(null); + setMsgType(null); + + try { + const registration = + await navigator.serviceWorker.getRegistration("/sw.js"); + if (registration) { + const subscription = await registration.pushManager.getSubscription(); + if (subscription) { + await fetch("/api/notifications/web-push", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ endpoint: subscription.endpoint }), + }); + + await subscription.unsubscribe(); + } + } + + setIsSubscribed(false); + setMsg(t("settings.webPushUnsubscribed")); + setMsgType("success"); + } catch { + setMsg(t("settings.webPushSubscribeFailed")); + setMsgType("error"); + } finally { + setActionLoading(false); + } + } + + return ( +
+
+
+ +

{t("settings.webPush")}

+
+
+ {!loading && isSubscribed && ( + + {t("settings.configured")} + + )} + {!loading && isSubscribed && ( + + {t("settings.webPushActive")} + + )} +
+
+

+ {t("settings.webPushDescription")} +

+ +
+ {loading ? ( +
+ +
+ ) : !isSupported ? ( +

+ {t("settings.webPushNotSupported")} +

+ ) : isDenied ? ( +

+ {t("settings.webPushDenied")} +

+ ) : ( +
+ {isSubscribed ? ( + + ) : ( + + )} + {isSubscribed && ( + + )} +
+ )} + + {msg && ( +

+ {msg} +

+ )} +
+
+ ); +}