mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
d7778c1085
commit
65e86e4a53
18 files changed
+4050
-3236
No files matched your search
+35
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 & 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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user