feat(settings): extract all six remaining sections — content lands at /settings/<slug> (#143)

Replace the SectionPlaceholder fallbacks for Account, AI, Integrations,
Notifications, Dashboard, API, Advanced, and About with real per-section
components. Every /settings/<slug> URL now renders working content; the
placeholder only fires for unknown slugs.

A2-content extraction
- Account: profile + passkeys + password change (account-section.tsx)
- AI: insights + per-user provider override (ai-section.tsx)
- Integrations: Withings + moodLog (integrations-section.tsx)
- Notifications: Telegram + ntfy + Web Push (split into telegram-card,
  ntfy-card, web-push-card to keep each file under ~600 LOC)
- Dashboard: layout + thresholds wrappers (dashboard-section.tsx)
- API: endpoints + tokens (api-section.tsx)
- Advanced: export + danger zone (advanced-section.tsx)
- About (greenfield): version, license, build SHA, repo/changelog/docs
  links + "Check for updates" against GitHub releases

A2-about
- Reads /api/version via TanStack Query, surfaces app version, AGPL-3.0
  badge, short build SHA, build timestamp, repo/changelog/docs links.
- "Check for updates" button compares against api.github.com latest
  release tag with up-to-date / newer-available / failure feedback.

A8-UI test buttons
- Shared TestConnectionButton primitive that POSTs to a test endpoint,
  surfaces ok-with-latency or translated meta.errorCode failures.
- Wired into Withings + moodLog (Integrations) and Web Push
  (Notifications). Glitchtip + Umami remain admin-only.

i18n
- New settings.about.* and settings.testConnection.* key groups in en.json
  and de.json.

Cleanup
- Delete src/app/settings/page.legacy.tsx (3230 LOC monolith).
- Convert sync-from-server patterns to the React-recommended
  prev-payload-id render-time pattern so the strict
  react-hooks/set-state-in-effect rule passes without a file-level disable.

Tests
- New src/components/settings/__tests__/sections.test.tsx with 9 SSR smoke
  tests (one per section) — total suite 514 (was 505).
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-08 14:52:04 +02:00
1 parent d7778c1085
commit 65e86e4a53
18 files changed
+4050 -3236

No files matched your search

+35 -1
View File
@@ -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",
+35 -1
View File
@@ -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",
+35 -4
View File
@@ -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 (
<SettingsShell active={section}>
<SectionPlaceholder slug={section} />
</SettingsShell>
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 ? (
<SectionComponent />
) : (
<SectionPlaceholder slug={section} />
);
return <SettingsShell active={section}>{body}</SettingsShell>;
}
File diff suppressed because it is too large. Load diff
@@ -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: () => <div data-testid="dashboard-layout" />,
}));
vi.mock("@/components/settings/thresholds-section", () => ({
ThresholdsSection: () => <div data-testid="thresholds" />,
}));
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(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("settings sections — SSR smoke", () => {
it("<AccountSection> renders the Account heading and Profile card", () => {
const html = render(<AccountSection />);
// 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("<AboutSection> renders version + license + check-for-updates button", () => {
const html = render(<AboutSection />);
expect(html).toContain("About");
// The new about.* keys resolve.
expect(html).toContain("Check for updates");
expect(html).not.toContain("settings.about.");
});
it("<AiSection> renders the AI Insights card", () => {
const html = render(<AiSection />);
expect(html).toContain("AI Insights");
});
it("<IntegrationsSection> renders Withings card title", () => {
const html = render(<IntegrationsSection />);
expect(html).toContain("Integrations");
expect(html).toContain("Withings");
});
it("<NotificationsSection> renders heading", () => {
const html = render(<NotificationsSection />);
expect(html).toContain("Notifications");
});
it("<DashboardSection> renders heading and embeds layout + thresholds", () => {
const html = render(<DashboardSection />);
expect(html).toContain("Dashboard");
expect(html).toContain('data-testid="dashboard-layout"');
expect(html).toContain('data-testid="thresholds"');
});
it("<ApiSection> renders endpoints + tokens cards", () => {
const html = render(<ApiSection />);
expect(html).toContain("API &amp; Tokens");
});
it("<AdvancedSection> renders export + danger-zone cards", () => {
const html = render(<AdvancedSection />);
expect(html).toContain("Advanced");
});
it("<AboutSection> resolves about.* keys in German", () => {
const html = render(<AboutSection />, "de");
expect(html).toContain("Auf Updates prüfen");
});
});
+308
View File
@@ -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 (
<section
aria-labelledby="settings-section-about-title"
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-about-title"
className="text-2xl font-semibold tracking-tight"
>
{t("settings.sections.about.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.about.description")}
</p>
</header>
<div className="bg-card border-border rounded-xl border p-6">
<div className="mb-4 flex items-center gap-2">
<Info className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">HealthLog</h2>
</div>
{isLoading || !version ? (
<div className="flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<span className="text-muted-foreground text-sm">
{t("common.loading")}
</span>
</div>
) : (
<dl className="grid gap-4 sm:grid-cols-2">
<div className="flex items-start gap-3">
<Package className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0">
<dt className="text-muted-foreground text-xs">
{t("settings.about.version")}
</dt>
<dd className="font-mono text-sm font-medium">
v{version.version}
</dd>
</div>
</div>
<div className="flex items-start gap-3">
<Scale className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0">
<dt className="text-muted-foreground text-xs">
{t("settings.about.license")}
</dt>
<dd>
<Badge variant="outline" className="text-xs">
{version.license}
</Badge>
</dd>
</div>
</div>
{version.buildSha && (
<div className="flex items-start gap-3">
<GitBranch className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0">
<dt className="text-muted-foreground text-xs">
{t("settings.about.gitSha")}
</dt>
<dd className="font-mono text-sm">
{version.buildSha.slice(0, 7)}
</dd>
</div>
</div>
)}
{version.builtAt && (
<div className="flex items-start gap-3">
<Sparkles className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0">
<dt className="text-muted-foreground text-xs">
{t("settings.about.builtAt", {
time: fmt.dateTime(version.builtAt),
})}
</dt>
<dd className="text-muted-foreground text-xs">
{fmt.date(version.builtAt)}
</dd>
</div>
</div>
)}
</dl>
)}
</div>
{/* Links */}
{version && (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<Button asChild variant="outline" size="sm">
<a
href={version.repository}
target="_blank"
rel="noopener noreferrer"
>
<GitBranch className="mr-2 h-4 w-4" />
{t("settings.about.repository")}
<ExternalLink className="ml-2 h-3.5 w-3.5" />
</a>
</Button>
<Button asChild variant="outline" size="sm">
<a
href={version.changelog}
target="_blank"
rel="noopener noreferrer"
>
<Sparkles className="mr-2 h-4 w-4" />
{t("settings.about.changelog")}
<ExternalLink className="ml-2 h-3.5 w-3.5" />
</a>
</Button>
<Button asChild variant="outline" size="sm">
<a href={version.docs} target="_blank" rel="noopener noreferrer">
<BookOpen className="mr-2 h-4 w-4" />
{t("settings.about.docs")}
<ExternalLink className="ml-2 h-3.5 w-3.5" />
</a>
</Button>
</div>
</div>
)}
{/* Check for updates */}
{version && (
<div className="bg-card border-border rounded-xl border p-6">
<Button
type="button"
variant="outline"
onClick={handleCheckForUpdates}
disabled={checking}
>
{checking ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
{t("settings.about.checkUpdates")}
</Button>
{updateResult?.kind === "up_to_date" && (
<p
role="status"
className="text-dracula-green mt-3 flex items-center gap-1.5 text-sm"
>
<CheckCircle2 className="h-4 w-4" />
{t("settings.about.upToDate")}
</p>
)}
{updateResult?.kind === "newer" && (
<p role="status" className="mt-3 flex items-center gap-1.5 text-sm">
<Sparkles className="text-dracula-purple h-4 w-4" />
<a
href={updateResult.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-2 hover:underline"
>
{t("settings.about.newerAvailable", {
tag: updateResult.tag,
})}
</a>
</p>
)}
{updateResult?.kind === "error" && (
<p
role="alert"
className="text-destructive mt-3 flex items-center gap-1.5 text-sm"
>
<XCircle className="h-4 w-4" />
{t("settings.testConnection.errors.connection_failed")}
</p>
)}
</div>
)}
</section>
);
}
+664
View File
@@ -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<string>("");
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState<string | null>(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<string | null>(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<string | null>(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<string | null>(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 (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin" />
</div>
);
}
if (!user) return null;
return (
<section
aria-labelledby="settings-section-account-title"
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-account-title"
className="text-2xl font-semibold tracking-tight"
>
{t("settings.sections.account.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.account.description")}
</p>
</header>
{/* Profile card */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="mb-4 flex items-center gap-2">
<User className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.profile")}</h2>
</div>
<form onSubmit={handleSaveProfile} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="username">{t("settings.username")}</Label>
<Input id="username" value={user.username} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="email">{t("auth.email")}</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t("auth.emailPlaceholder")}
maxLength={320}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="gender">{t("settings.gender")}</Label>
<select
id="gender"
value={gender}
onChange={(e) => setGender(e.target.value)}
className="border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
<option value="">{t("settings.genderNone")}</option>
<option value="MALE">{t("settings.genderMale")}</option>
<option value="FEMALE">{t("settings.genderFemale")}</option>
</select>
<p className="text-muted-foreground text-xs">
{t("settings.genderHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="height">{t("settings.height")}</Label>
<Input
id="height"
type="number"
value={heightCm}
onChange={(e) => setHeightCm(e.target.value)}
placeholder="175"
min={50}
max={300}
step={0.1}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="dob">{t("settings.dateOfBirth")}</Label>
<Input
id="dob"
type="date"
value={dateOfBirth}
onChange={(e) => setDateOfBirth(e.target.value)}
max={new Date().toISOString().slice(0, 10)}
/>
<p className="text-muted-foreground text-xs">
{t("settings.dateOfBirthHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="language-select">{t("settings.language")}</Label>
<select
id="language-select"
value={locale}
onChange={(e) => setLocale(e.target.value as Locale)}
className="border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
{locales.map((loc) => (
<option key={loc} value={loc}>
{localeLabels[loc as Locale]}
</option>
))}
</select>
<p className="text-muted-foreground text-xs">
{t("settings.languageDescription")}
</p>
</div>
</div>
{saveMsg && (
<p
role="alert"
className={`text-sm ${
saveMsgType === "success"
? "text-dracula-green"
: "text-destructive"
}`}
>
{saveMsg}
</p>
)}
<div className="flex justify-end">
<Button type="submit" disabled={saving}>
{saving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{t("common.save")}
</Button>
</div>
</form>
</div>
{/* Passkeys card */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="mb-4 flex items-center gap-2">
<Shield className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.passkeys")}</h2>
</div>
<PasskeyListSection isAuthenticated={isAuthenticated} />
<div className="mt-4 flex justify-end">
<Button
variant="outline"
onClick={handleAddPasskey}
disabled={passkeyLoading}
>
{passkeyLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<KeyRound className="mr-2 h-4 w-4" />
)}
{t("settings.addPasskey")}
</Button>
</div>
{passkeyMsg && (
<p
role="alert"
className={`mt-2 text-right text-sm ${
passkeyMsgType === "success"
? "text-dracula-green"
: "text-destructive"
}`}
>
{passkeyMsg}
</p>
)}
</div>
{/* Password card */}
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Shield className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.passwordReset")}
</h2>
</div>
<Button
type="button"
variant="outline"
onClick={() => setPasswordDialogOpen(true)}
>
{t("settings.changePassword")}
</Button>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.changePasswordDescription")}
</p>
</div>
<Dialog
open={passwordDialogOpen}
onOpenChange={(open) => {
setPasswordDialogOpen(open);
if (!open) {
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setPasswordMsg(null);
setPasswordMsgType(null);
}
}}
>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{t("settings.passwordReset")}</DialogTitle>
<DialogDescription>
{t("settings.changePasswordDescription")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleChangePassword} className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1.5">
<Label htmlFor="current-password">
{t("settings.currentPassword")}
</Label>
<PasswordInput
id="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-password">
{t("settings.newPassword")}
</Label>
<PasswordInput
id="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm-password">
{t("settings.confirmNewPassword")}
</Label>
<PasswordInput
id="confirm-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
</div>
{passwordMsg && (
<p
role="alert"
className={`text-sm ${
passwordMsgType === "success"
? "text-dracula-green"
: "text-destructive"
}`}
>
{passwordMsg}
</p>
)}
<Button type="submit" variant="outline" disabled={passwordSaving}>
{passwordSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{t("settings.changePassword")}
</Button>
</form>
</DialogContent>
</Dialog>
</section>
);
}
function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [deleteMsg, setDeleteMsg] = useState<string | null>(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<string, string> = {
singleDevice: t("settings.singleDevice"),
multiDevice: t("settings.multiDevice"),
};
if (!passkeys || passkeys.length === 0) {
return (
<div>
<h3 className="text-sm font-medium">{t("settings.passkeys")}</h3>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.noPasskeys")}
</p>
</div>
);
}
return (
<div>
<h3 className="text-sm font-medium">
{t("settings.registeredPasskeys")}
</h3>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.passkeysDescription")}
</p>
<div className="border-border mt-3 overflow-x-auto rounded-lg border">
<table className="w-full min-w-[620px] text-sm">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("settings.passkeyName")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.passkeyDevice")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.passkeyBackup")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.passkeyCreated")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("settings.passkeyActions")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{passkeys.map((pk, idx) => (
<tr key={pk.id} className={idx % 2 === 0 ? "bg-muted/20" : ""}>
<td className="px-3 py-2 font-medium">{pk.name}</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
pk.credentialDeviceType}
</td>
<td className="px-3 py-2 text-xs">
<Badge
variant={pk.credentialBackedUp ? "secondary" : "outline"}
className="text-[11px]"
>
{pk.credentialBackedUp
? t("settings.backedUp")
: t("common.no")}
</Badge>
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{formatDate(pk.createdAt)}
</td>
<td className="px-3 py-2 text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-destructive h-8 w-8"
disabled={deletePasskey.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.deletePasskey")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.deletePasskeyDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deletePasskey.mutate(pk.id)}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</td>
</tr>
))}
</tbody>
</table>
</div>
{deleteMsg && (
<div
role="alert"
className="text-destructive mt-2 flex items-center gap-2 text-sm"
>
<AlertTriangle className="h-4 w-4" />
{deleteMsg}
</div>
)}
</div>
);
}
@@ -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 (
<section
aria-labelledby="settings-section-advanced-title"
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-advanced-title"
className="text-2xl font-semibold tracking-tight"
>
{t("settings.sections.advanced.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.advanced.description")}
</p>
</header>
<ExportCard />
<DataResetCard />
</section>
);
}
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 (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<Download className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.export")}</h2>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.exportDescription")}
</p>
<div className="mt-4 flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleExport("json")}
disabled={exporting}
>
{exporting && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
{t("settings.exportJson")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleExport("csv")}
disabled={exporting}
>
{exporting && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
{t("settings.exportCsv")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDoctorReport}
disabled={generatingReport}
>
{generatingReport && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
)}
{t("settings.doctorReport")}
</Button>
</div>
</div>
);
}
function DataResetCard() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [deleting, setDeleting] = useState(false);
const [msg, setMsg] = useState<string | null>(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 (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<AlertTriangle className="text-destructive h-5 w-5" />
<h2 className="text-destructive text-lg font-semibold">
{t("settings.dangerZone")}
</h2>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.dangerZoneDescription")}
</p>
<div className="mt-4">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={deleting}>
{deleting ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.dangerZone")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.dangerZoneConfirm")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.dangerZoneConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDeleteAllData}
>
{t("settings.finalDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{msg && (
<p
role="alert"
className={`mt-3 text-sm ${msgType === "success" ? "text-dracula-green" : "text-destructive"}`}
>
{msg}
</p>
)}
</div>
);
}
+595
View File
@@ -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 (
<section aria-labelledby="settings-section-ai-title" className="space-y-6">
<header className="space-y-1">
<h1
id="settings-section-ai-title"
className="text-2xl font-semibold tracking-tight"
>
{t("settings.sections.ai.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.ai.description")}
</p>
</header>
<InsightsSettingsCard isAuthenticated={isAuthenticated} />
</section>
);
}
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<string | null>(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<string, unknown>) => {
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 (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Sparkles className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.kiInsights")}</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{settings?.codexStatus === "connected" && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
ChatGPT verbunden
</Badge>
)}
{settings?.codexStatus !== "connected" && settings?.hasAdminKey && (
<Badge className="border-dracula-purple/30 bg-dracula-purple/15 text-dracula-purple">
Admin-KI aktiv
</Badge>
)}
{settings?.codexStatus === "expired" && (
<Badge className="border-dracula-orange/30 bg-dracula-orange/15 text-dracula-orange">
Verbindung abgelaufen
</Badge>
)}
{settings?.lastInsightAt && (
<Badge variant="outline" className="text-xs">
{t("settings.lastGeneratedAt")}:{" "}
{formatDateTime(settings.lastInsightAt)}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.kiInsightsDescription")}
</p>
<div className="mt-4 space-y-4">
<div className="bg-muted/50 rounded-lg p-4">
{settings?.codexStatus === "connected" ? (
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">ChatGPT verbunden</p>
<p className="text-muted-foreground text-xs">
Insights werden über dein ChatGPT-Abo generiert — keine
zusätzlichen Kosten.
{settings.codexConnectedAt && (
<>
{" "}
Verbunden seit {formatDateTime(settings.codexConnectedAt)}
.
</>
)}
</p>
</div>
<Button
variant="ghost"
size="sm"
className="text-destructive shrink-0"
onClick={handleDisconnect}
disabled={disconnecting}
>
{disconnecting ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-1 h-4 w-4" />
)}
Trennen
</Button>
</div>
) : (
<div className="space-y-3">
<div>
<p className="text-sm font-medium">Mit ChatGPT verbinden</p>
<p className="text-muted-foreground text-xs">
Verbinde dein ChatGPT Pro/Max-Konto um KI-gestützte
Gesundheitsanalysen basierend auf aktuellen medizinischen
Leitlinien zu erhalten. Keine zusätzlichen API-Kosten.
</p>
</div>
<Button
variant="outline"
onClick={handleConnect}
className="w-full sm:w-auto"
>
<Sparkles className="mr-2 h-4 w-4" />
Mit ChatGPT verbinden
</Button>
{settings?.hasAdminKey && (
<p className="text-muted-foreground text-xs">
Alternativ nutzt HealthLog den vom Administrator
konfigurierten KI-Anbieter.
</p>
)}
</div>
)}
</div>
{msg && (
<p
role="alert"
className={`text-sm ${msgType === "success" ? "text-dracula-green" : "text-destructive"}`}
>
{msg}
</p>
)}
{hasProvider && (
<div className="bg-muted/50 rounded-lg p-4">
<div className="flex items-center justify-between gap-4">
<div className="pr-2">
<p className="text-sm font-medium">{t("settings.rawData")}</p>
<p className="text-muted-foreground text-xs">
{settings?.privacyMode === "raw"
? t("settings.rawDataOnDescription")
: t("settings.rawDataOffDescription")}
</p>
</div>
<div className="ml-2 shrink-0">
<Switch
checked={settings?.privacyMode === "raw"}
onCheckedChange={togglePrivacyMode}
/>
</div>
</div>
{settings?.privacyMode === "raw" && (
<div className="mt-2 rounded-lg bg-orange-500/10 p-2 text-xs text-orange-400">
{t("settings.rawDataWarning")}
</div>
)}
</div>
)}
{hasProvider && (
<Button
variant="outline"
onClick={handleRegenerate}
disabled={regenerating}
className="w-full sm:w-auto"
>
{regenerating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
{t("settings.regenerateInsights")}
</Button>
)}
<UserAIProviderSubsection />
</div>
</div>
);
}
function UserAIProviderSubsection() {
const queryClient = useQueryClient();
const [provider, setProvider] = useState<string>("");
const [model, setModel] = useState<string>("");
const [baseUrl, setBaseUrl] = useState<string>("");
const [anthropicKey, setAnthropicKey] = useState<string>("");
const [localKey, setLocalKey] = useState<string>("");
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saveOk, setSaveOk] = useState<boolean>(false);
const [testMsg, setTestMsg] = useState<string | null>(null);
const [testOk, setTestOk] = useState<boolean>(false);
const [testing, setTesting] = useState<boolean>(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<string | null>(null);
if (dataKey && dataKey !== seededKey) {
setSeededKey(dataKey);
setProvider(data!.provider ?? "");
setModel(data!.model ?? "");
setBaseUrl(data!.baseUrl ?? "");
}
const saveMutation = useMutation({
mutationFn: async () => {
const body: Record<string, unknown> = {
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<string, string> = {};
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 (
<div className="bg-muted/50 mt-2 rounded-lg p-4">
<div className="mb-3">
<p className="text-sm font-medium">KI-Provider (persönlich)</p>
<p className="text-muted-foreground text-xs">
Eigener KI-Anbieter überschreibt die Admin-Einstellung. Leer lassen
für Standard.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label htmlFor="ai-provider-select">Provider</Label>
<select
id="ai-provider-select"
value={provider}
onChange={(e) => setProvider(e.target.value)}
className="bg-background border-input mt-1 h-9 w-full rounded-md border px-2 text-sm"
>
<option value="">— Standard (Admin/Codex) —</option>
<option value="OPENAI">OpenAI</option>
<option value="ANTHROPIC">Anthropic (Claude)</option>
<option value="LOCAL">Lokal (OpenAI-kompatibel)</option>
<option value="CHATGPT_OAUTH">ChatGPT OAuth</option>
</select>
</div>
<div>
<Label htmlFor="ai-model-input">Modell</Label>
<Input
id="ai-model-input"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder={
provider === "ANTHROPIC"
? "claude-3-5-sonnet-latest"
: provider === "LOCAL"
? "llama3:8b"
: "gpt-4o-mini"
}
className="mt-1"
/>
</div>
{provider === "ANTHROPIC" && (
<div className="sm:col-span-2">
<Label htmlFor="ai-anthropic-key">
Anthropic API Key
{data?.hasAnthropicKey && (
<span className="text-muted-foreground ml-2 text-xs">
(gespeichert {data.anthropicKeyPreview})
</span>
)}
</Label>
<PasswordInput
id="ai-anthropic-key"
value={anthropicKey}
onChange={(e) => setAnthropicKey(e.target.value)}
placeholder="sk-ant-..."
className="mt-1"
/>
</div>
)}
{provider === "LOCAL" && (
<>
<div className="sm:col-span-2">
<Label htmlFor="ai-base-url">Base URL</Label>
<Input
id="ai-base-url"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="http://localhost:11434/v1"
className="mt-1"
/>
</div>
<div className="sm:col-span-2">
<Label htmlFor="ai-local-key">API Key (optional)</Label>
<PasswordInput
id="ai-local-key"
value={localKey}
onChange={(e) => setLocalKey(e.target.value)}
placeholder={data?.hasLocalKey ? "(gespeichert)" : ""}
className="mt-1"
/>
</div>
</>
)}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button
size="sm"
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Speichern
</Button>
<Button
size="sm"
variant="outline"
onClick={runTest}
disabled={testing}
>
{testing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
Verbindung testen
</Button>
</div>
{saveMsg && (
<p
className={`mt-2 text-xs ${
saveOk ? "text-dracula-green" : "text-destructive"
}`}
>
{saveMsg}
</p>
)}
{testMsg && (
<p
className={`mt-2 text-xs ${
testOk ? "text-dracula-green" : "text-destructive"
}`}
>
{testMsg}
</p>
)}
</div>
);
}
+409
View File
@@ -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 (
<section aria-labelledby="settings-section-api-title" className="space-y-6">
<header className="space-y-1">
<h1
id="settings-section-api-title"
className="text-2xl font-semibold tracking-tight"
>
{t("settings.sections.api.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.api.description")}
</p>
</header>
<ApiEndpointsCard />
<ApiTokensCard />
</section>
);
}
function ApiEndpointsCard() {
const { t } = useTranslations();
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<Key className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("settings.apiEndpointsTitle")}
</h2>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.apiEndpointsDescription")}
</p>
<div className="border-border mt-4 overflow-x-auto rounded-lg border">
<table className="w-full min-w-[760px] text-xs">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b">
<th className="px-3 py-2 text-left font-medium">
{t("settings.apiEndpointMethod")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.apiEndpointPath")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.apiEndpointAuth")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.apiEndpointExample")}
</th>
</tr>
</thead>
<tbody>
<tr>
<td className="px-3 py-2 font-medium">POST</td>
<td className="px-3 py-2 font-mono">/api/ingest/medication</td>
<td className="px-3 py-2 font-mono">
Authorization: Bearer hlk_...
</td>
<td className="px-3 py-2 font-mono">
{`{ "medicationName": "...", "scheduledFor": "..." }`}
</td>
</tr>
</tbody>
</table>
</div>
</div>
);
}
function ApiTokensCard() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const [newName, setNewName] = useState("");
const [newToken, setNewToken] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [tokenMsg, setTokenMsg] = useState<string | null>(null);
const [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<string | null>((latest, tok) => {
if (!tok.lastUsedAt) return latest;
if (!latest || new Date(tok.lastUsedAt) > new Date(latest)) {
return tok.lastUsedAt;
}
return latest;
}, null);
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Key className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.apiTokens")}</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{activeTokens.length > 0 && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("settings.configured")}
</Badge>
)}
{latestActiveUse && (
<Badge variant="outline" className="text-xs">
{t("settings.tokenTableLastUsed")}:{" "}
{formatDateTime(latestActiveUse)}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.apiTokensDescription")}
</p>
<div className="mt-4 space-y-4">
<form onSubmit={handleCreate} className="flex gap-2">
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("settings.tokenNamePlaceholder")}
maxLength={100}
className="flex-1"
/>
<Button
type="submit"
variant="outline"
size="sm"
disabled={creating || !newName.trim()}
>
{creating && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
{t("common.create")}
</Button>
</form>
{newToken && (
<div className="bg-dracula-green/10 rounded-lg p-3 text-sm">
<p className="text-dracula-green mb-1 font-medium">
{t("settings.tokenCreated")}
</p>
<code className="bg-muted block rounded p-2 font-mono text-xs break-all">
{newToken}
</code>
</div>
)}
{tokenMsg && (
<p role="alert" className="text-destructive text-sm">
{tokenMsg}
</p>
)}
<div>
<p className="mb-2 text-sm font-medium">
{t("settings.activeTokensTitle")}
</p>
<div className="border-border overflow-x-auto rounded-lg border">
<table className="w-full min-w-[860px] text-sm">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableName")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTablePermissions")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableStatus")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableCreated")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableLastUsed")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("settings.tokenTableActions")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{activeTokens.length === 0 && (
<tr>
<td
colSpan={6}
className="text-muted-foreground px-3 py-4 text-center text-sm"
>
{t("settings.noActiveTokens")}
</td>
</tr>
)}
{activeTokens.map((tok, index) => {
const isExpired =
tok.expiresAt && new Date(tok.expiresAt) < new Date();
return (
<tr
key={tok.id}
className={index % 2 === 0 ? "bg-muted/20" : ""}
>
<td className="px-3 py-2 font-medium">{tok.name}</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{tok.permissions.join(", ")}
</td>
<td className="px-3 py-2">
{isExpired ? (
<Badge variant="destructive" className="text-xs">
{t("settings.tokenExpired")}
</Badge>
) : (
<Badge className="bg-dracula-green/15 text-dracula-green text-xs">
{t("settings.tokenActive")}
</Badge>
)}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{formatDate(tok.createdAt)}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{tok.lastUsedAt
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</td>
<td className="px-3 py-2 text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-destructive h-8 w-8"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.tokenRevoke")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.tokenRevokeDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => handleRevoke(tok.id)}
>
{t("settings.tokenRevoked")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{revokedTokens.length > 0 && (
<div className="space-y-2">
<button
type="button"
onClick={() => setShowRevokedTokens((prev) => !prev)}
className="text-foreground hover:text-primary flex items-center gap-1 text-sm font-medium transition-colors"
>
<ChevronDown
className={`h-4 w-4 transition-transform ${showRevokedTokens ? "rotate-180" : ""}`}
/>
{t("settings.revokedTokensTitle", {
count: revokedTokens.length,
})}
</button>
{showRevokedTokens && (
<div className="border-border overflow-x-auto rounded-lg border">
<table className="w-full min-w-[760px] text-sm">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableName")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTablePermissions")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableCreated")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.tokenTableLastUsed")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{revokedTokens.map((tok, index) => (
<tr
key={tok.id}
className={index % 2 === 0 ? "bg-muted/20" : ""}
>
<td className="px-3 py-2 font-medium">{tok.name}</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{tok.permissions.join(", ")}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{formatDate(tok.createdAt)}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{tok.lastUsedAt
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
</div>
);
}
@@ -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 (
<section
aria-labelledby="settings-section-dashboard-title"
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-dashboard-title"
className="text-2xl font-semibold tracking-tight"
>
{t("settings.sections.dashboard.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.dashboard.description")}
</p>
</header>
<DashboardLayoutSection id="dashboard-layout" />
<ThresholdsSection id="thresholds" />
</section>
);
}
@@ -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 (
<section
aria-labelledby="settings-section-integrations-title"
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-integrations-title"
className="text-2xl font-semibold tracking-tight"
>
{t("settings.sections.integrations.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.integrations.description")}
</p>
</header>
<WithingsCard isAuthenticated={isAuthenticated} />
{moodLogEnabled && <MoodLogCard />}
</section>
);
}
function WithingsCard({ isAuthenticated }: { isAuthenticated: boolean }) {
const { t } = useTranslations();
const [syncing, setSyncing] = useState(false);
const [syncMsg, setSyncMsg] = useState<string | null>(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<string | null>(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 (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Link2 className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.withings")}</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{status?.configured && !status?.connected && (
<Badge variant="outline" className="text-xs">
{t("settings.configured")}
</Badge>
)}
{status?.connected && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("settings.withingsConnected")}
</Badge>
)}
{status?.lastSyncedAt && (
<Badge variant="outline" className="text-xs">
{t("settings.withingsLastSync")}:{" "}
{formatDateTime(status.lastSyncedAt)}
</Badge>
)}
{status?.tokenExpired && (
<Badge variant="destructive" className="text-xs">
{t("settings.withingsTokenExpired")}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.withingsDescription")}
</p>
<div className="mt-4 space-y-4">
<div className="space-y-3">
<h3 className="text-sm font-medium">
{t("settings.withingsCredentials")}
</h3>
<form onSubmit={handleSaveCredentials} className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1.5">
<Label htmlFor="w-clientid">
{t("settings.withingsClientId")}
</Label>
<Input
id="w-clientid"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
placeholder={
status?.configured
? t("settings.withingsCredentialsSavedPlaceholder")
: t("settings.withingsClientId")
}
maxLength={200}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="w-secret">
{t("settings.withingsClientSecret")}
</Label>
<PasswordInput
id="w-secret"
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
placeholder={
status?.configured
? t("settings.withingsCredentialsSavedPlaceholderSecret")
: t("settings.withingsClientSecret")
}
maxLength={200}
/>
</div>
<div className="space-y-1.5">
<Label className="invisible">{t("common.save")}</Label>
<Button
type="submit"
variant="outline"
size="sm"
className="h-9 w-full"
disabled={
credsSaving || !clientId.trim() || !clientSecret.trim()
}
>
{credsSaving ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Save className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.withingsSaveCredentials")}
</Button>
</div>
</div>
{credsMsg && (
<p
role="alert"
className={`text-sm ${credsMsgType === "success" ? "text-dracula-green" : "text-destructive"}`}
>
{credsMsg}
</p>
)}
</form>
</div>
{status?.connected ? (
<>
<div className="flex flex-wrap items-start gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleSync(false)}
disabled={syncing}
>
{syncing ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.withingsSync")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={syncing}>
{syncing ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.withingsFullSync")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.withingsFullSyncTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.withingsFullSyncDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => handleSync(true)}>
{t("settings.withingsSynchronize")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<TestConnectionButton
endpoint="/api/integrations/withings/test"
disabled={!status?.connected}
/>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive"
>
<Unlink className="mr-1 h-3.5 w-3.5" />
{t("settings.withingsDisconnect")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.withingsDisconnectTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.withingsDisconnectDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => disconnect.mutate()}
>
{t("settings.withingsDisconnect")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{syncMsg && (
<p
role="alert"
className={`text-sm ${syncMsgType === "success" ? "text-dracula-green" : "text-destructive"}`}
>
{syncMsg}
</p>
)}
</>
) : status?.configured ? (
<Button
variant="outline"
onClick={() => {
window.location.href = "/api/withings/connect";
}}
>
<Link2 className="mr-2 h-4 w-4" />
{t("settings.withingsConnect")}
</Button>
) : (
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-sm">
{t("settings.withingsNoCredentials")}
</div>
)}
</div>
</div>
);
}
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<string | null>(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 (
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Smile className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
<a
href="https://moodlog.onback.io"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{t("settings.moodLogTitle")}
</a>
</h2>
</div>
{status?.configured && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{status.enabled
? t("settings.withingsConnected")
: t("settings.configured")}
</Badge>
)}
</div>
<p className="text-muted-foreground text-xs">
{t("settings.moodLogDescription")}
</p>
<form onSubmit={handleSave} className="space-y-3">
<div>
<Label>{t("settings.moodLogUrl")}</Label>
<Input
type="url"
placeholder={t("settings.moodLogUrlPlaceholder")}
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
<div>
<Label>{t("settings.moodLogApiKey")}</Label>
<PasswordInput
placeholder={
status?.configured
? t("settings.withingsCredentialsSavedPlaceholder")
: t("settings.moodLogApiKeyPlaceholder")
}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
</div>
<div className="flex flex-wrap items-start gap-2">
<Button
type="submit"
disabled={saving || (!url.trim() && !apiKey.trim())}
size="sm"
>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Save className="mr-2 h-4 w-4" />
{t("common.save")}
</Button>
<TestConnectionButton
endpoint="/api/integrations/moodlog/test"
disabled={!status?.configured}
/>
</div>
</form>
{status?.configured && (
<div className="space-y-3 border-t pt-3">
{status.webhookSecret && (
<div>
<Label>{t("settings.moodLogWebhookSecret")}</Label>
<div className="flex gap-2">
<Input
value={status.webhookSecret}
readOnly
className="font-mono text-xs"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
navigator.clipboard.writeText(status.webhookSecret!);
setMsg(t("common.copied"));
setMsgType("success");
}}
>
{t("common.copied").replace("!", "")}
</Button>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.moodLogWebhookSecretHelp")}
</p>
</div>
)}
<div className="flex flex-wrap gap-4 text-sm">
{status.lastSyncedAt && (
<span>
{t("settings.moodLogLastSync")}:{" "}
{formatDateTime(status.lastSyncedAt)}
</span>
)}
<span>
{t("settings.moodLogEntries")}: {status.entryCount}
</span>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={syncing}
onClick={() => handleSync(false)}
>
{syncing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<RefreshCw className="mr-2 h-4 w-4" />
{t("settings.moodLogSync")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={syncing}>
<Download className="mr-2 h-4 w-4" />
{t("settings.moodLogFullSync")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.moodLogFullSyncTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.moodLogFullSyncDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => handleSync(true)}>
{t("settings.moodLogFullSync")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm">
<Unlink className="mr-2 h-4 w-4" />
{t("settings.moodLogDisconnect")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.moodLogDisconnectTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.moodLogDisconnectDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleDisconnect}>
{t("settings.moodLogDisconnect")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
)}
{msg && (
<p
role="alert"
className={`text-sm ${msgType === "error" ? "text-destructive" : "text-dracula-green"}`}
>
{msg}
</p>
)}
</div>
);
}
@@ -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 (
<section
aria-labelledby="settings-section-notifications-title"
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-notifications-title"
className="text-2xl font-semibold tracking-tight"
>
{t("settings.sections.notifications.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.notifications.description")}
</p>
</header>
{showTelegram && <TelegramCard isAuthenticated={isAuthenticated} />}
{showNtfy && <NtfyCard isAuthenticated={isAuthenticated} />}
{showWebPush && <WebPushCard />}
</section>
);
}
+215
View File
@@ -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<string | null>(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<string | null>(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 (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Bell className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.ntfy")}</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{settings?.serverUrl && settings?.topic && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("settings.configured")}
</Badge>
)}
{settings?.enabled && (
<Badge variant="outline" className="text-xs">
{t("common.enabled")}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.ntfyDescription")}
</p>
<div className="mt-4 space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="ntfy-toggle">{t("settings.ntfyEnable")}</Label>
<Switch
id="ntfy-toggle"
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => save.mutate(checked)}
disabled={save.isPending}
/>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
save.mutate(settings?.enabled ?? false);
}}
className="space-y-4"
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="ntfy-server">{t("settings.ntfyServer")}</Label>
<Input
id="ntfy-server"
placeholder="https://ntfy.sh"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ntfy-topic">{t("settings.ntfyTopic")}</Label>
<Input
id="ntfy-topic"
placeholder="healthlog-mein-topic"
value={topic}
onChange={(e) => setTopic(e.target.value)}
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="ntfy-auth">{t("settings.ntfyAuthToken")}</Label>
<PasswordInput
id="ntfy-auth"
placeholder={
settings?.hasAuthToken
? t("settings.saved")
: t("common.optional")
}
value={authToken}
onChange={(e) => setAuthToken(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
{t("settings.ntfyAuthTokenHint")}
</p>
</div>
</div>
{saveMsg && (
<p
role="alert"
className={`text-sm ${saveMsgType === "success" ? "text-dracula-green" : "text-destructive"}`}
>
{saveMsg}
</p>
)}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
disabled={test.isPending || !settings?.enabled}
onClick={() => test.mutate()}
>
{test.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Send className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.testMessage")}
</Button>
<Button type="submit" disabled={save.isPending}>
{save.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
)}
{t("common.save")}
</Button>
</div>
</form>
</div>
</div>
);
}
@@ -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<typeof Input>) {
const [visible, setVisible] = useState(false);
return (
<div className="relative">
<Input {...props} type={visible ? "text" : "password"} />
<button
type="button"
tabIndex={-1}
onClick={() => setVisible((v) => !v)}
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2"
>
{visible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
);
}
+217
View File
@@ -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<string | null>(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<string | null>(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<string, unknown> = { 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 (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<MessageCircle className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.telegram")}</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{settings?.hasBotToken && settings?.chatId && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("settings.configured")}
</Badge>
)}
{settings?.enabled && (
<Badge variant="outline" className="text-xs">
{t("common.enabled")}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.telegramDescription")}
</p>
<div className="mt-4 space-y-4">
<form onSubmit={handleSave} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="tg-token">{t("settings.botToken")}</Label>
<PasswordInput
id="tg-token"
value={botToken}
onChange={(e) => setBotToken(e.target.value)}
placeholder={
settings?.hasBotToken
? t("settings.withingsCredentialsSavedPlaceholder")
: "123456:ABC-DEF..."
}
maxLength={100}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tg-chatid">{t("settings.chatId")}</Label>
<Input
id="tg-chatid"
value={chatId}
onChange={(e) => setChatId(e.target.value)}
placeholder="123456789"
maxLength={50}
/>
</div>
</div>
<div className="flex items-center gap-3">
<Switch
id="tg-enabled"
checked={enabled}
onCheckedChange={setEnabled}
/>
<Label htmlFor="tg-enabled" className="cursor-pointer">
{t("settings.enableNotifications")}
</Label>
</div>
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-xs">
<p>{t("settings.telegramStep1")}</p>
<p>{t("settings.telegramStep2")}</p>
<p>{t("settings.telegramStep3")}</p>
</div>
{msg && (
<p
role="alert"
className={`text-sm ${
msgType === "success"
? "text-dracula-green"
: "text-destructive"
}`}
>
{msg}
</p>
)}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
disabled={testing || !settings?.hasBotToken}
onClick={handleTest}
>
{testing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
{t("settings.testMessage")}
</Button>
<Button type="submit" disabled={saving}>
{saving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{t("common.save")}
</Button>
</div>
</form>
</div>
</div>
);
}
@@ -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 (
<div className="space-y-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleClick}
disabled={disabled || testing}
>
{testing ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Plug className="mr-1 h-3.5 w-3.5" />
)}
{testing
? t("settings.testConnection.testing")
: (label ?? t("settings.testConnection.test"))}
</Button>
{result?.kind === "ok" && (
<p
role="status"
className="text-dracula-green flex items-center gap-1.5 text-xs"
>
<CheckCircle2 className="h-3.5 w-3.5" />
{t("settings.testConnection.ok", { latency: result.latency })}
</p>
)}
{result?.kind === "error" && (
<p
role="alert"
className="text-destructive flex items-center gap-1.5 text-xs"
>
<XCircle className="h-3.5 w-3.5" />
{describeError(result.errorCode)}
</p>
)}
</div>
);
}
+241
View File
@@ -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<string | null>(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 (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<BellRing className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.webPush")}</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{!loading && isSubscribed && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("settings.configured")}
</Badge>
)}
{!loading && isSubscribed && (
<Badge variant="outline" className="text-xs">
{t("settings.webPushActive")}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.webPushDescription")}
</p>
<div className="mt-4 space-y-3">
{loading ? (
<div className="flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
</div>
) : !isSupported ? (
<p className="text-muted-foreground text-sm">
{t("settings.webPushNotSupported")}
</p>
) : isDenied ? (
<p className="text-destructive text-sm">
{t("settings.webPushDenied")}
</p>
) : (
<div className="flex flex-wrap items-start gap-2">
{isSubscribed ? (
<Button
variant="outline"
size="sm"
onClick={handleUnsubscribe}
disabled={actionLoading}
>
{actionLoading && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
)}
{t("settings.webPushUnsubscribe")}
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={handleSubscribe}
disabled={actionLoading}
>
{actionLoading ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<BellRing className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.webPushSubscribe")}
</Button>
)}
{isSubscribed && (
<TestConnectionButton endpoint="/api/notifications/web-push/test" />
)}
</div>
)}
{msg && (
<p
role="alert"
className={`text-sm ${msgType === "success" ? "text-dracula-green" : "text-destructive"}`}
>
{msg}
</p>
)}
</div>
</div>
);
}