release: v1.4.2 — resurrect v1.4.1 work + bug-fix triple (#152)

* Revert "revert: roll source tree back to v1.4.0 to restore production"

This reverts commit e444a90b10.

* fix(v1.4.2): summaries.WEIGHT + medications array guards + healthcheck IPv4

Three production bugs surfaced after the v1.4.0 deploy stabilized:

1. `TypeError: undefined is not an object (evaluating 'E?.summaries.WEIGHT')`
   on /dashboard and /insights for users without weight readings —
   the optional chain stopped at `data` when it needed to also cover
   `data.summaries`. Fixed: `data?.summaries?.WEIGHT` (one level deeper).

2. `TypeError: y?.filter is not a function` on /medications — the
   medications query value was non-array (likely a stale
   service-worker response from an older API shape). Replaced with
   `Array.isArray(medications) ? medications : []` so the page is
   immune to any future shape drift.

3. Healthcheck-induced 503 outage (already in 1.4.1 — included here
   so 1.4.2 is the single coherent release). The docker-compose
   healthcheck used `localhost` which busybox-wget resolves to IPv6
   ::1; Next.js standalone listens on IPv4 0.0.0.0 only. Pinned
   to 127.0.0.1.

This release also includes everything from v1.4.1 (which never
published a clean GHCR tag): the per-section admin extraction, the
testcontainers integration suite, the Playwright + axe-core E2E
foundation, the moodLog SSRF fix, the central error-redaction in
WideEventBuilder + Glitchtip, the medical-citation drift fixes
(ESH 2023, Saint-Maurice attribution, steps source), and the docs
sync.

Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
This commit is contained in:
Marc-André BombeckandMarc-André Bombeck authored and GitHub committed 2026-05-08 17:46:31 +02:00
1 parent 956d29032f
commit 643e6ec3a0
59 files changed
+7318 -3844

No files matched your search

+14 -2639
View File
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -372,7 +372,13 @@ export const GET = apiHandler(async () => {
unit: "steps",
range: stepsRange,
classification: stepsClassification,
source: "WHO",
// WHO publishes activity *time* (150–300 min/wk moderate),
// not a step quota. The closest peer-reviewed dose-response
// for the 8 000–15 000 band is Saint-Maurice JAMA 2020. The
// AI prompts at src/lib/insights/prompts/{base-system,
// general-status}.ts already enforce this attribution; this
// surface label was the last "WHO" mislabel in the codebase.
source: "Saint-Maurice JAMA 2020",
});
// 8. Medication Compliance (average across active medications)
+1 -1
View File
@@ -632,7 +632,7 @@ export default function InsightsPage() {
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
const w = analytics?.summaries.WEIGHT;
const w = analytics?.summaries?.WEIGHT;
const sys = analytics?.summaries.BLOOD_PRESSURE_SYS;
const dia = analytics?.summaries.BLOOD_PRESSURE_DIA;
const p = analytics?.summaries.PULSE;
+57 -49
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
@@ -130,10 +130,11 @@ export default function MedicationsPage() {
const byName = (a: Medication, b: Medication) =>
a.name.localeCompare(b.name, "de", { sensitivity: "base" });
const activeMeds = (medications?.filter((m) => m.active) ?? []).sort(byName);
const inactiveMeds = (medications?.filter((m) => !m.active) ?? []).sort(
byName,
);
// Defensive against stale service-worker responses or any future API
// shape change: only filter when we actually have an array.
const medsArray = Array.isArray(medications) ? medications : [];
const activeMeds = medsArray.filter((m) => m.active).sort(byName);
const inactiveMeds = medsArray.filter((m) => !m.active).sort(byName);
return (
<div className="space-y-5">
@@ -485,20 +486,52 @@ function ApiEndpointDialog({
onClose: () => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
type ExampleType = "curl" | "wget" | "fetch" | "powershell";
const [enabled, setEnabled] = useState(false);
const [activeTokenCount, setActiveTokenCount] = useState(0);
const [loadingStatus, setLoadingStatus] = useState(false);
const apiEndpointKey = ["medication-api-endpoint", medication?.id];
type ApiEndpointStatus = { enabled: boolean; activeTokenCount: number };
const {
data: status,
isFetching: loadingStatus,
refetch: refetchStatus,
error: statusError,
} = useQuery<ApiEndpointStatus>({
queryKey: apiEndpointKey,
queryFn: async () => {
const res = await fetch(
`/api/medications/${medication!.id}/api-endpoint`,
);
const json = await res.json();
if (!res.ok) {
throw new Error(json.error || t("medications.statusLoadFailed"));
}
return {
enabled: json.data.enabled === true,
activeTokenCount: json.data.activeTokenCount ?? 0,
};
},
enabled: !!medication,
staleTime: 0,
gcTime: 0,
});
const enabled = status?.enabled ?? false;
const activeTokenCount = status?.activeTokenCount ?? 0;
const [token, setToken] = useState<string | null>(null);
const [toggling, setToggling] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const [exampleType, setExampleType] = useState<ExampleType>("curl");
const displayMsg =
msg ?? (statusError instanceof Error ? statusError.message : null);
function handleClose() {
setEnabled(false);
setActiveTokenCount(0);
queryClient.removeQueries({ queryKey: apiEndpointKey });
setToken(null);
setMsg(null);
setCopied(null);
@@ -506,27 +539,6 @@ function ApiEndpointDialog({
onClose();
}
const loadStatus = useCallback(async () => {
if (!medication) return;
setLoadingStatus(true);
setMsg(null);
try {
const res = await fetch(`/api/medications/${medication.id}/api-endpoint`);
if (res.ok) {
const json = await res.json();
setEnabled(json.data.enabled === true);
setActiveTokenCount(json.data.activeTokenCount ?? 0);
} else {
const json = await res.json();
setMsg(json.error || t("medications.statusLoadFailed"));
}
} catch {
setMsg(t("medications.statusLoadFailed"));
} finally {
setLoadingStatus(false);
}
}, [medication, t]);
async function toggleEndpoint(nextEnabled: boolean) {
if (!medication) return;
setToggling(true);
@@ -547,15 +559,16 @@ function ApiEndpointDialog({
return;
}
setEnabled(json.data.enabled === true);
if (typeof json.data.activeTokenCount === "number") {
setActiveTokenCount(json.data.activeTokenCount);
} else if (
!json.data.enabled &&
typeof json.data.revokedTokenCount === "number"
) {
setActiveTokenCount(0);
}
const nextStatus: ApiEndpointStatus = {
enabled: json.data.enabled === true,
activeTokenCount:
typeof json.data.activeTokenCount === "number"
? json.data.activeTokenCount
: !json.data.enabled
? 0
: (status?.activeTokenCount ?? 0),
};
queryClient.setQueryData<ApiEndpointStatus>(apiEndpointKey, nextStatus);
if (json.data.token) {
setToken(json.data.token);
@@ -579,11 +592,6 @@ function ApiEndpointDialog({
setTimeout(() => setCopied(null), 2000);
}
useEffect(() => {
if (!medication) return;
void loadStatus();
}, [medication, loadStatus]);
const baseUrl =
typeof window !== "undefined" ? window.location.origin : "https://...";
const endpoint = `${baseUrl}/api/ingest/medication`;
@@ -756,11 +764,11 @@ function ApiEndpointDialog({
)}
</div>
{msg && (
{displayMsg && (
<p
className={`text-sm ${msg === t("medications.apiEndpointActivated") || msg === t("medications.apiEndpointDeactivated") ? "text-dracula-green" : "text-destructive"}`}
className={`text-sm ${displayMsg === t("medications.apiEndpointActivated") || displayMsg === t("medications.apiEndpointDeactivated") ? "text-dracula-green" : "text-destructive"}`}
>
{msg}
{displayMsg}
</p>
)}
@@ -780,7 +788,7 @@ function ApiEndpointDialog({
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={() => {
void loadStatus();
void refetchStatus();
}}
disabled={loadingStatus || toggling}
>
+1 -1
View File
@@ -197,7 +197,7 @@ export default function DashboardPage() {
enabled: isAuthenticated,
});
const w = data?.summaries.WEIGHT;
const w = data?.summaries?.WEIGHT;
const sys = data?.summaries.BLOOD_PRESSURE_SYS;
const dia = data?.summaries.BLOOD_PRESSURE_DIA;
const p = data?.summaries.PULSE;
+276
View File
@@ -0,0 +1,276 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Eye, EyeOff } from "lucide-react";
import { toast } from "sonner";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
export interface AdminUser {
id: string;
username: string;
email: string | null;
role: string;
createdAt: string;
passkeyCount: number;
}
export interface WorkerStatus {
running: boolean;
startedAt: string | null;
lastHeartbeat: string | null;
lastReminderCheck: string | null;
lastWithingsSync: string | null;
lastInsightsRun: string | null;
jobsProcessed: number;
errors: number;
}
export interface SystemStatus {
version: string;
nodeVersion: string;
gitCommit: string;
buildTime: string;
startTime: string;
database: string;
worker: WorkerStatus;
counts: {
users: number;
measurements: number;
medications: number;
intakeEvents: number;
activeTokens: number;
activeSessions: number;
};
integrations: {
umami: { configured: boolean; enabled: boolean } | null;
glitchtip: { configured: boolean; enabled: boolean } | null;
webPush: { configured: boolean } | null;
bugReport: { configured: boolean } | null;
};
}
export interface AdminSettings {
registrationEnabled: boolean;
defaultLocale: string;
telegramGlobal: boolean;
ntfyGlobal: boolean;
webPushGlobal: boolean;
webPushVapidPublicKey: string | null;
webPushVapidSubject: string | null;
webPushVapidConfigured: boolean;
apiGlobal: boolean;
umamiEnabled: boolean;
umamiScriptUrl: string | null;
umamiWebsiteId: string | null;
glitchtipEnabled: boolean;
glitchtipDsn: string | null;
glitchtipEnvironment: string | null;
bugReportRepo: string | null;
bugReportConfigured: boolean;
reminderLateMinutes: number;
reminderMissedMinutes: number;
}
export interface AdminAuditEntry {
id: string;
action: string;
ipAddress: string | null;
location: string | null;
details: string | null;
createdAt: string;
user: { id: string; username: string } | null;
}
export interface ApiTokenInfo {
id: string;
name: string;
permissions: string[];
lastUsedAt: string | null;
expiresAt: string | null;
createdAt: string;
revoked: boolean;
user: { id: string; username: string };
}
export type FeedbackStatusType =
| "OPEN"
| "ACKNOWLEDGED"
| "RESOLVED"
| "ARCHIVED";
export type FeedbackCategoryType =
| "BUG"
| "FEATURE_REQUEST"
| "QUESTION"
| "OTHER";
export interface FeedbackItem {
id: string;
userId: string | null;
email: string | null;
category: FeedbackCategoryType;
subject: string;
description: string;
status: FeedbackStatusType;
adminNote: string | null;
gitHubIssueUrl: string | null;
metadata: Record<string, unknown> | null;
screenshotBase64: string | null;
createdAt: string;
updatedAt: string;
user: { username: string } | null;
}
export interface FeedbackListResponse {
items: FeedbackItem[];
meta: {
total: number;
limit: number;
offset: number;
countsByStatus: Partial<Record<FeedbackStatusType, number>>;
};
}
export const FEEDBACK_STATUS_TABS: FeedbackStatusType[] = [
"OPEN",
"ACKNOWLEDGED",
"RESOLVED",
"ARCHIVED",
];
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>
);
}
export function StatusItem({
icon: Icon,
label,
value,
className,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: string;
className?: string;
}) {
return (
<div className="bg-muted/50 rounded-lg p-3">
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
<Icon className="h-3.5 w-3.5" />
{label}
</div>
<p className={`mt-1 text-sm font-semibold ${className ?? ""}`}>{value}</p>
</div>
);
}
export function SettingsToggle({
label,
description,
icon: Icon,
checked,
onCheckedChange,
disabled,
}: {
label: string;
description: string;
icon?: React.ComponentType<{ className?: string }>;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled: boolean;
}) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{Icon && <Icon className="text-muted-foreground h-4 w-4" />}
<div>
<p className="text-sm font-medium">{label}</p>
<p className="text-muted-foreground text-xs">{description}</p>
</div>
</div>
<Switch
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
/>
</div>
);
}
export function useAdminSettings() {
return useQuery({
queryKey: ["admin", "settings"],
queryFn: async () => {
const res = await fetch("/api/admin/settings");
if (!res.ok) throw new Error("Failed");
return (await res.json()).data as AdminSettings;
},
});
}
export function useUpdateSettings() {
const queryClient = useQueryClient();
const { t } = useTranslations();
return useMutation({
mutationFn: async (data: Record<string, unknown>) => {
const res = await fetch("/api/admin/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) {
throw new Error(await getApiErrorMessage(res));
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin", "settings"] });
toast.success(t("common.saved"));
},
onError: (err) => {
toast.error(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
);
},
});
}
export async function getApiErrorMessage(response: Response): Promise<string> {
const fallback = `HTTP ${response.status}`;
try {
const json = (await response.json()) as { error?: string };
if (typeof json?.error === "string" && json.error.trim().length > 0) {
return json.error;
}
} catch {
return fallback;
}
return fallback;
}
export function useSystemStatus() {
return useQuery({
queryKey: ["admin", "status"],
queryFn: async () => {
const res = await fetch("/api/admin/status");
if (!res.ok) throw new Error("Failed");
return (await res.json()).data as SystemStatus;
},
});
}
@@ -0,0 +1,144 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, Key, Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { formatDate, formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { type ApiTokenInfo } from "./_shared";
export function ApiTokenOverviewSection({ id }: { id: string }) {
const { t } = useTranslations();
const [expanded, setExpanded] = useState(false);
const { data: tokens, isLoading } = useQuery({
queryKey: ["admin", "tokens"],
queryFn: async () => {
const res = await fetch("/api/admin/tokens");
if (!res.ok) throw new Error("Failed");
return (await res.json()).data as ApiTokenInfo[];
},
enabled: expanded,
});
return (
<div
id={id}
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
>
<div className="flex items-center 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("admin.apiTokens")}</h2>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
>
{expanded ? t("settings.collapse") : t("settings.expand")}
<ChevronDown
className={`ml-1 h-4 w-4 transition-transform ${expanded ? "rotate-180" : ""}`}
/>
</Button>
</div>
{expanded && (
<div className="mt-4">
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
</div>
) : !tokens?.length ? (
<p className="text-muted-foreground text-sm">
{t("admin.noTokens")}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("admin.tokenUser")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.tokenName")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.tokenPermissions")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.tokenStatus")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.tokenLastUsed")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.tokenCreated")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{tokens.map((token, i) => {
const isExpired =
token.expiresAt && new Date(token.expiresAt) < new Date();
return (
<tr
key={token.id}
className={i % 2 === 0 ? "bg-muted/30" : ""}
>
<td className="px-3 py-2 font-medium">
{token.user.username}
</td>
<td className="px-3 py-2">{token.name}</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-1">
{token.permissions.map((p) => (
<Badge
key={p}
variant="secondary"
className="text-xs"
>
{p}
</Badge>
))}
</div>
</td>
<td className="px-3 py-2 text-right">
{token.revoked ? (
<Badge variant="destructive" className="text-xs">
{t("settings.tokenRevoked")}
</Badge>
) : isExpired ? (
<Badge variant="destructive" className="text-xs">
{t("settings.tokenExpired")}
</Badge>
) : (
<Badge className="bg-dracula-green/15 text-dracula-green text-xs">
{t("common.active")}
</Badge>
)}
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
{token.lastUsedAt
? formatDateTime(token.lastUsedAt)
: t("admin.tokenNeverUsed")}
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
{formatDate(token.createdAt)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useState } from "react";
import { Bug, Loader2 } 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 { useTranslations } from "@/lib/i18n/context";
import { PasswordInput, useAdminSettings, useUpdateSettings } from "./_shared";
export function BugReportSection({ id }: { id: string }) {
const { t } = useTranslations();
const { data: settings } = useAdminSettings();
const updateSettings = useUpdateSettings();
const [bugReportRepoDraft, setBugReportRepoDraft] = useState<string | null>(
null,
);
const [bugReportTokenDraft, setBugReportTokenDraft] = useState("");
const bugReportRepoValue =
bugReportRepoDraft ?? settings?.bugReportRepo ?? "";
const configured = settings?.bugReportConfigured ?? false;
function saveBugReportSettings() {
const payload: Record<string, unknown> = {
bugReportRepo: bugReportRepoValue,
};
if (bugReportTokenDraft.trim().length > 0) {
payload.bugReportToken = bugReportTokenDraft.trim();
}
updateSettings.mutate(payload, {
onSuccess: () => {
setBugReportRepoDraft(null);
setBugReportTokenDraft("");
},
});
}
return (
<div
id={id}
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Bug className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("admin.bugReportGithub")}
</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{configured && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("admin.configured")}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.bugReportGithubDescription")}
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="admin-bugreport-repo" className="text-xs">
{t("admin.bugReportRepo")}
</Label>
<Input
id="admin-bugreport-repo"
value={bugReportRepoValue}
onChange={(event) => setBugReportRepoDraft(event.target.value)}
placeholder={t("admin.bugReportRepoPlaceholder")}
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
disabled={updateSettings.isPending}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="admin-bugreport-token" className="text-xs">
{t("admin.bugReportToken")}
</Label>
<PasswordInput
id="admin-bugreport-token"
value={bugReportTokenDraft}
onChange={(event) => setBugReportTokenDraft(event.target.value)}
placeholder={t("admin.bugReportTokenPlaceholder")}
disabled={updateSettings.isPending}
/>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
size="sm"
onClick={saveBugReportSettings}
disabled={updateSettings.isPending}
>
{updateSettings.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
)}
{t("common.save")}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,118 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, 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 DangerZoneSection({ id }: { id: string }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [wipeMsg, setWipeMsg] = useState<string | null>(null);
const wipeAllData = useMutation({
mutationFn: async () => {
const res = await fetch("/api/admin/data", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: "DELETE ALL" }),
});
const json = await res.json();
if (!res.ok) throw new Error(json.error || t("common.error"));
return json.data as {
measurements: number;
intakeEvents: number;
medications: number;
};
},
onSuccess: (data) => {
queryClient.invalidateQueries();
setWipeMsg(
t("admin.deletedResult", {
measurements: data.measurements,
medications: data.medications,
intakeEvents: data.intakeEvents,
}),
);
},
onError: (err: Error) => {
setWipeMsg(err.message);
},
});
return (
<div
id={id}
className="bg-destructive/5 border-destructive/30 scroll-mt-28 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("admin.dangerZone")}
</h2>
</div>
<div className="mt-4">
<p className="text-sm font-medium">{t("admin.deleteAllData")}</p>
<p className="text-muted-foreground text-xs">
{t("admin.deleteAllDescription")}
</p>
<div className="mt-3">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={wipeAllData.isPending}
>
{wipeAllData.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="mr-1 h-3.5 w-3.5" />
)}
{t("admin.deleteButton")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("admin.deleteAllConfirm")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("admin.deleteAllConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => wipeAllData.mutate()}
>
{t("admin.finalDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{wipeMsg && (
<p
className={`mt-2 text-sm ${wipeMsg.startsWith(t("admin.deletedResult", { measurements: "", medications: "", intakeEvents: "" }).split(":")[0]) ? "text-dracula-green" : "text-destructive"}`}
>
{wipeMsg}
</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,511 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
CheckCircle2,
ExternalLink,
GitPullRequest,
Inbox,
Loader2,
MessageSquare,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
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 { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import {
FEEDBACK_STATUS_TABS,
type FeedbackCategoryType,
type FeedbackItem,
type FeedbackListResponse,
type FeedbackStatusType,
getApiErrorMessage,
useSystemStatus,
} from "./_shared";
export function FeedbackInboxSection({ id }: { id: string }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { data: status } = useSystemStatus();
const githubConfigured = Boolean(status?.integrations.bugReport?.configured);
const [activeStatus, setActiveStatus] = useState<FeedbackStatusType>("OPEN");
const [selected, setSelected] = useState<FeedbackItem | null>(null);
const { data, isLoading } = useQuery({
queryKey: ["admin", "feedback", activeStatus],
queryFn: async () => {
const res = await fetch(
`/api/admin/feedback?status=${activeStatus}&limit=100`,
);
if (!res.ok) throw new Error("Failed");
return (await res.json()).data as FeedbackListResponse;
},
});
const counts = data?.meta.countsByStatus ?? {};
function refresh() {
queryClient.invalidateQueries({ queryKey: ["admin", "feedback"] });
}
return (
<div
id={id}
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
>
<div className="flex items-center gap-2">
<Inbox className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("admin.feedback.title")}</h2>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.feedback.description")}
</p>
<Tabs
value={activeStatus}
onValueChange={(v) => setActiveStatus(v as FeedbackStatusType)}
className="mt-4"
>
<TabsList>
{FEEDBACK_STATUS_TABS.map((s) => (
<TabsTrigger key={s} value={s}>
<span>
{t(
`admin.feedback.tab${s.charAt(0) + s.slice(1).toLowerCase()}`,
)}
</span>
<Badge variant="secondary" className="ml-1.5 text-xs">
{counts[s] ?? 0}
</Badge>
</TabsTrigger>
))}
</TabsList>
{FEEDBACK_STATUS_TABS.map((s) => (
<TabsContent key={s} value={s} className="mt-4">
{isLoading ? (
<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("admin.feedback.loading")}
</span>
</div>
) : !data?.items.length ? (
<p className="text-muted-foreground text-sm">
{t("admin.feedback.noEntries")}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("admin.feedback.createdAt")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.feedback.category")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.feedback.subject")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.feedback.user")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.feedback.actions")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{data.items.map((item, i) => (
<tr
key={item.id}
className={`hover:bg-muted/40 cursor-pointer ${i % 2 === 0 ? "bg-muted/30" : ""}`}
onClick={() => setSelected(item)}
>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{formatDateTime(item.createdAt)}
</td>
<td className="px-3 py-2">
<FeedbackCategoryBadge category={item.category} />
</td>
<td className="max-w-[28ch] truncate px-3 py-2 font-medium">
{item.subject}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{item.user?.username ?? t("admin.feedback.anonymous")}
</td>
<td className="px-3 py-2 text-right">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={(e) => {
e.stopPropagation();
setSelected(item);
}}
>
{t("admin.feedback.details")}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TabsContent>
))}
</Tabs>
{selected && (
<FeedbackDetailDialog
item={selected}
open={!!selected}
onOpenChange={(open) => !open && setSelected(null)}
githubConfigured={githubConfigured}
onMutated={refresh}
/>
)}
</div>
);
}
function FeedbackCategoryBadge({
category,
}: {
category: FeedbackCategoryType;
}) {
const { t } = useTranslations();
const map: Record<
FeedbackCategoryType,
{ label: string; className: string }
> = {
BUG: {
label: t("admin.feedback.categoryBug"),
className: "bg-red-500/15 text-red-400 border-red-500/30",
},
FEATURE_REQUEST: {
label: t("admin.feedback.categoryFeature"),
className: "bg-purple-500/15 text-purple-400 border-purple-500/30",
},
QUESTION: {
label: t("admin.feedback.categoryQuestion"),
className: "bg-blue-500/15 text-blue-400 border-blue-500/30",
},
OTHER: {
label: t("admin.feedback.categoryOther"),
className: "bg-muted text-muted-foreground border-border",
},
};
const cfg = map[category];
return (
<Badge className={`border text-xs ${cfg.className}`}>{cfg.label}</Badge>
);
}
function FeedbackDetailDialog({
item,
open,
onOpenChange,
githubConfigured,
onMutated,
}: {
item: FeedbackItem;
open: boolean;
onOpenChange: (open: boolean) => void;
githubConfigured: boolean;
onMutated: () => void;
}) {
const { t } = useTranslations();
const [note, setNote] = useState(item.adminNote ?? "");
const [issueUrl, setIssueUrl] = useState(item.gitHubIssueUrl);
const update = useMutation({
mutationFn: async (payload: {
status?: FeedbackStatusType;
adminNote?: string | null;
}) => {
const res = await fetch(`/api/admin/feedback/${item.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(await getApiErrorMessage(res));
},
onSuccess: (_, vars) => {
onMutated();
if (vars.adminNote !== undefined) {
toast.success(t("admin.feedback.noteSaved"));
}
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : t("admin.feedback.updateFailed"),
);
},
});
const archive = useMutation({
mutationFn: async () => {
const res = await fetch(`/api/admin/feedback/${item.id}`, {
method: "DELETE",
});
if (!res.ok) throw new Error(await getApiErrorMessage(res));
},
onSuccess: () => {
onMutated();
onOpenChange(false);
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : t("admin.feedback.updateFailed"),
);
},
});
const publish = useMutation({
mutationFn: async () => {
const res = await fetch(`/api/admin/feedback/${item.id}/github`, {
method: "POST",
});
if (!res.ok) throw new Error(await getApiErrorMessage(res));
return (await res.json()).data as { issueUrl: string };
},
onSuccess: (data) => {
setIssueUrl(data.issueUrl);
toast.success(t("admin.feedback.publishSuccess"));
onMutated();
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : t("admin.feedback.publishFailed"),
);
},
});
const meta = item.metadata ?? {};
const url = typeof meta.url === "string" ? meta.url : null;
const locale = typeof meta.locale === "string" ? meta.locale : null;
const userAgent = typeof meta.userAgent === "string" ? meta.userAgent : null;
const appVersion =
typeof meta.appVersion === "string" ? meta.appVersion : null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
{item.subject}
</DialogTitle>
<DialogDescription>
<span className="flex flex-wrap items-center gap-2 text-xs">
<FeedbackCategoryBadge category={item.category} />
<span>
{t("admin.feedback.submittedBy")}:{" "}
{item.user?.username ?? t("admin.feedback.anonymous")}
</span>
<span>·</span>
<span>{formatDateTime(item.createdAt)}</span>
{issueUrl && (
<a
href={issueUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary inline-flex items-center gap-1"
>
<GitPullRequest className="h-3 w-3" />
{t("admin.feedback.viewIssue")}
<ExternalLink className="h-3 w-3" />
</a>
)}
</span>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 text-sm">
<div className="bg-muted/40 rounded-md p-3 whitespace-pre-wrap">
{item.description}
</div>
{(url || locale || userAgent || appVersion) && (
<div>
<h4 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
{t("admin.feedback.metadataHeading")}
</h4>
<dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-xs">
{url && (
<>
<dt className="text-muted-foreground">
{t("admin.feedback.metaUrl")}
</dt>
<dd className="font-mono break-all">{url}</dd>
</>
)}
{locale && (
<>
<dt className="text-muted-foreground">
{t("admin.feedback.metaLocale")}
</dt>
<dd className="font-mono">{locale}</dd>
</>
)}
{appVersion && (
<>
<dt className="text-muted-foreground">
{t("admin.feedback.metaAppVersion")}
</dt>
<dd className="font-mono">{appVersion}</dd>
</>
)}
{userAgent && (
<>
<dt className="text-muted-foreground">
{t("admin.feedback.metaUserAgent")}
</dt>
<dd className="font-mono break-all">{userAgent}</dd>
</>
)}
</dl>
</div>
)}
{item.screenshotBase64 && (
<div>
<h4 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
{t("admin.feedback.screenshotHeading")}
</h4>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.screenshotBase64}
alt="Screenshot"
className="border-border max-h-72 rounded-md border"
/>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="admin-feedback-note" className="text-xs">
{t("admin.feedback.adminNote")}
</Label>
<textarea
id="admin-feedback-note"
value={note}
onChange={(e) => setNote(e.target.value)}
rows={3}
maxLength={5000}
placeholder={t("admin.feedback.adminNotePlaceholder")}
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
/>
<div className="flex justify-end">
<Button
size="sm"
variant="outline"
disabled={update.isPending || note === (item.adminNote ?? "")}
onClick={() => update.mutate({ adminNote: note || null })}
>
{update.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
)}
{t("admin.feedback.saveNote")}
</Button>
</div>
</div>
<div className="border-border flex flex-wrap items-center gap-2 border-t pt-3">
<Button
size="sm"
variant="outline"
disabled={update.isPending || item.status === "ACKNOWLEDGED"}
onClick={() => update.mutate({ status: "ACKNOWLEDGED" })}
>
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
{t("admin.feedback.actionAcknowledge")}
</Button>
<Button
size="sm"
disabled={update.isPending || item.status === "RESOLVED"}
onClick={() => update.mutate({ status: "RESOLVED" })}
>
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
{t("admin.feedback.actionResolve")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="destructive"
disabled={archive.isPending || item.status === "ARCHIVED"}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
{t("admin.feedback.actionArchive")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("admin.feedback.archiveConfirm")}
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => archive.mutate()}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t("admin.feedback.actionArchive")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{githubConfigured && !issueUrl && (
<Button
size="sm"
variant="outline"
disabled={publish.isPending}
onClick={() => publish.mutate()}
className="ml-auto"
>
{publish.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<GitPullRequest className="mr-1.5 h-3.5 w-3.5" />
)}
{t("admin.feedback.actionPublishGithub")}
</Button>
)}
{issueUrl && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green ml-auto">
{t("admin.feedback.publishedToGithub")}
</Badge>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,54 @@
"use client";
import { Settings } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
export function GeneralSettingsSection({ id }: { id: string }) {
const { t } = useTranslations();
const { data: settings } = useAdminSettings();
const updateSettings = useUpdateSettings();
return (
<div
id={id}
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
>
<div className="flex items-center gap-2">
<Settings className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("admin.appSettings")}</h2>
</div>
<div className="mt-4 space-y-4">
<SettingsToggle
label={t("admin.registrationEnabled")}
description={t("admin.registrationEnabledDescription")}
checked={settings?.registrationEnabled ?? true}
onCheckedChange={(checked) =>
updateSettings.mutate({ registrationEnabled: checked })
}
disabled={updateSettings.isPending}
/>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{t("admin.defaultLanguage")}</p>
<p className="text-muted-foreground text-xs">
{t("admin.defaultLanguageDescription")}
</p>
</div>
<select
value={settings?.defaultLocale ?? "de"}
onChange={(e) =>
updateSettings.mutate({ defaultLocale: e.target.value })
}
disabled={updateSettings.isPending}
className="border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-none"
>
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</div>
</div>
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { AlertTriangle, Loader2 } from "lucide-react";
import { toast } from "sonner";
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 { useTranslations } from "@/lib/i18n/context";
import {
SettingsToggle,
getApiErrorMessage,
useAdminSettings,
useUpdateSettings,
} from "./_shared";
export function GlitchtipSection({ id }: { id: string }) {
const { t } = useTranslations();
const { data: settings } = useAdminSettings();
const updateSettings = useUpdateSettings();
const [glitchtipDsnDraft, setGlitchtipDsnDraft] = useState<string | null>(
null,
);
const [glitchtipEnvironmentDraft, setGlitchtipEnvironmentDraft] = useState<
string | null
>(null);
const glitchtipDsnValue = glitchtipDsnDraft ?? settings?.glitchtipDsn ?? "";
const glitchtipEnvironmentValue =
glitchtipEnvironmentDraft ?? settings?.glitchtipEnvironment ?? "production";
const configured = Boolean(settings?.glitchtipDsn);
const testGlitchtip = useMutation({
mutationFn: async () => {
const res = await fetch("/api/admin/monitoring/glitchtip-test", {
method: "POST",
});
if (!res.ok) {
throw new Error(await getApiErrorMessage(res));
}
const json = (await res.json()) as { data?: { message?: string } };
return json.data?.message ?? t("admin.monitoringTestSuccess");
},
onSuccess: (message) => {
toast.success(message);
},
onError: (error) => {
toast.error(
error instanceof Error
? error.message
: t("admin.monitoringTestFailed"),
);
},
});
function saveGlitchtipSettings() {
updateSettings.mutate(
{
glitchtipDsn: glitchtipDsnValue,
glitchtipEnvironment: glitchtipEnvironmentValue,
},
{
onSuccess: () => {
setGlitchtipDsnDraft(null);
setGlitchtipEnvironmentDraft(null);
},
},
);
}
return (
<div
id={id}
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<AlertTriangle className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("admin.glitchtipTitle")}</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{configured && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("admin.configured")}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.glitchtipDescription")}
</p>
<div className="mt-4 space-y-3">
<SettingsToggle
label="GlitchTip"
description={t("admin.glitchtipEnabled")}
icon={AlertTriangle}
checked={settings?.glitchtipEnabled ?? false}
onCheckedChange={(checked) =>
updateSettings.mutate({ glitchtipEnabled: checked })
}
disabled={updateSettings.isPending}
/>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="admin-glitchtip-dsn" className="text-xs">
{t("admin.glitchtipDsn")}
</Label>
<Input
id="admin-glitchtip-dsn"
name="admin-glitchtip-dsn"
value={glitchtipDsnValue}
onChange={(event) => setGlitchtipDsnDraft(event.target.value)}
placeholder={t("admin.glitchtipDsnPlaceholder")}
autoComplete="new-password"
spellCheck={false}
data-lpignore="true"
data-1p-ignore="true"
disabled={updateSettings.isPending}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="admin-glitchtip-environment" className="text-xs">
{t("admin.glitchtipEnvironment")}
</Label>
<Input
id="admin-glitchtip-environment"
name="admin-glitchtip-environment"
value={glitchtipEnvironmentValue}
onChange={(event) =>
setGlitchtipEnvironmentDraft(event.target.value)
}
placeholder={t("admin.glitchtipEnvironmentPlaceholder")}
autoComplete="new-password"
spellCheck={false}
data-lpignore="true"
data-1p-ignore="true"
disabled={updateSettings.isPending}
/>
</div>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => testGlitchtip.mutate()}
disabled={testGlitchtip.isPending || updateSettings.isPending}
>
{testGlitchtip.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
)}
{t("common.test")}
</Button>
<Button
size="sm"
onClick={saveGlitchtipSettings}
disabled={updateSettings.isPending}
>
{updateSettings.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
)}
{t("common.save")}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,181 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
CheckCircle2,
ChevronDown,
Loader2,
ScrollText,
XCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { type AdminAuditEntry } from "./_shared";
export function LoginOverviewSection({ id }: { id: string }) {
const { t } = useTranslations();
const [expanded, setExpanded] = useState(false);
const [filter, setFilter] = useState<"all" | "failed">("all");
const AUTH_ACTION_LABELS: Record<string, string> = {
"auth.register": t("admin.authRegister"),
"auth.login": t("admin.authLogin"),
"auth.login.passkey": t("admin.authLoginPasskey"),
"auth.login.password": t("admin.authLoginPassword"),
"auth.login.failed": t("admin.authLoginFailed"),
"auth.logout": t("admin.authLogout"),
"auth.passkey.register": t("admin.authPasskeyRegister"),
"auth.passkey.delete": t("admin.authPasskeyDelete"),
};
const { data, isLoading } = useQuery({
queryKey: ["admin", "audit-log", filter],
queryFn: async () => {
const res = await fetch(`/api/admin/audit-log?limit=100&filter=auth`);
if (!res.ok) throw new Error("Failed");
return (await res.json()).data as {
entries: AdminAuditEntry[];
meta: { total: number };
};
},
enabled: expanded,
});
const entries =
filter === "failed"
? data?.entries.filter((e) => e.action === "auth.login.failed")
: data?.entries;
return (
<div
id={id}
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<ScrollText className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("admin.loginOverview")}</h2>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
>
{expanded ? t("settings.collapse") : t("settings.expand")}
<ChevronDown
className={`ml-1 h-4 w-4 transition-transform ${expanded ? "rotate-180" : ""}`}
/>
</Button>
</div>
{expanded && (
<div className="mt-4 space-y-3">
<div className="flex gap-1">
<Button
variant={filter === "all" ? "default" : "ghost"}
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setFilter("all")}
>
{t("admin.allAuthEvents")}
</Button>
<Button
variant={filter === "failed" ? "default" : "ghost"}
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setFilter("failed")}
>
{t("admin.failedOnly")}
</Button>
</div>
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
</div>
) : !entries?.length ? (
<p className="text-muted-foreground text-sm">
{t("admin.noEntries")}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("admin.status")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.users")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.action")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.ip")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.location")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.timestamp")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{entries.map((entry, i) => {
const isFailed = entry.action === "auth.login.failed";
return (
<tr
key={entry.id}
className={i % 2 === 0 ? "bg-muted/30" : ""}
>
<td className="px-3 py-2">
{isFailed ? (
<XCircle className="text-destructive h-4 w-4" />
) : (
<CheckCircle2 className="text-dracula-green h-4 w-4" />
)}
</td>
<td
className={`px-3 py-2 font-medium ${isFailed ? "text-destructive" : ""}`}
>
{entry.user?.username ?? t("common.unknown")}
</td>
<td
className={`px-3 py-2 text-xs ${isFailed ? "text-destructive" : "text-muted-foreground"}`}
>
{AUTH_ACTION_LABELS[entry.action] ?? entry.action}
</td>
<td className="text-muted-foreground px-3 py-2 text-right font-mono text-xs">
{entry.ipAddress ?? "—"}
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs">
{entry.location ?? "—"}
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
{formatDateTime(entry.createdAt)}
</td>
</tr>
);
})}
</tbody>
</table>
{data && data.meta.total > entries.length && (
<p className="text-muted-foreground mt-3 text-center text-xs">
{t("admin.showingEntries", {
count: entries.length,
total: data.meta.total,
})}
</p>
)}
</div>
)}
</div>
)}
</div>
);
}
+318
View File
@@ -0,0 +1,318 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import {
Activity,
Bell,
CheckCircle2,
Clock,
Loader2,
XCircle,
} from "lucide-react";
import { toast } from "sonner";
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 { useTranslations } from "@/lib/i18n/context";
import {
getApiErrorMessage,
useAdminSettings,
useUpdateSettings,
} from "./_shared";
export function RemindersSection({ id }: { id: string }) {
const { t } = useTranslations();
const { data: settings } = useAdminSettings();
const updateSettings = useUpdateSettings();
const [reminderLateDraft, setReminderLateDraft] = useState<number | null>(
null,
);
const [reminderMissedDraft, setReminderMissedDraft] = useState<number | null>(
null,
);
const testNotification = useMutation({
mutationFn: async () => {
const res = await fetch("/api/admin/notifications/test", {
method: "POST",
});
if (!res.ok) {
throw new Error(await getApiErrorMessage(res));
}
const json = (await res.json()) as {
data?: {
message?: string;
results?: Array<{
channel: string;
success: boolean;
error?: string;
}>;
};
};
return json.data;
},
onSuccess: (data) => {
const hasFailures = data?.results?.some((r) => !r.success);
if (hasFailures) {
toast.error(data?.message ?? t("admin.notificationTestFailed"));
} else {
toast.success(data?.message ?? t("admin.notificationTestSuccess"));
}
},
onError: (error) => {
toast.error(
error instanceof Error
? error.message
: t("admin.notificationTestFailed"),
);
},
});
const reminderCheck = useMutation({
mutationFn: async () => {
const res = await fetch("/api/admin/notifications/reminder-check", {
method: "POST",
});
if (!res.ok) {
throw new Error(await getApiErrorMessage(res));
}
const json = (await res.json()) as {
data?: {
message?: string;
medications?: Array<{
name: string;
dose: string;
user: string;
localTime: string;
dayOfWeek: string;
notificationsEnabled: boolean;
schedules: Array<{
window: string;
days: string;
status: string;
label: string;
notificationSent?: boolean;
}>;
eventsToday: number;
}>;
notificationsSent?: number;
};
};
return json.data;
},
onSuccess: (data) => {
toast.success(data?.message ?? t("admin.reminderCheckSuccess"));
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : t("admin.reminderCheckFailed"),
);
},
});
return (
<div
id={id}
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
>
<div className="flex items-center gap-2">
<Clock className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("admin.medicationReminders")}
</h2>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.medicationRemindersDescription")}
</p>
<div className="mt-4 space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="admin-reminder-late" className="text-xs">
{t("admin.reminderLateMinutes")}
</Label>
<p className="text-muted-foreground text-xs">
{t("admin.reminderLateMinutesDescription")}
</p>
<Input
id="admin-reminder-late"
type="number"
min={15}
max={480}
value={reminderLateDraft ?? settings?.reminderLateMinutes ?? 120}
onChange={(e) => setReminderLateDraft(Number(e.target.value))}
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
disabled={updateSettings.isPending}
className="w-32"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="admin-reminder-missed" className="text-xs">
{t("admin.reminderMissedMinutes")}
</Label>
<p className="text-muted-foreground text-xs">
{t("admin.reminderMissedMinutesDescription")}
</p>
<Input
id="admin-reminder-missed"
type="number"
min={30}
max={720}
value={
reminderMissedDraft ?? settings?.reminderMissedMinutes ?? 240
}
onChange={(e) => setReminderMissedDraft(Number(e.target.value))}
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
disabled={updateSettings.isPending}
className="w-32"
/>
</div>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => testNotification.mutate()}
disabled={testNotification.isPending}
>
{testNotification.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
)}
<Bell className="mr-1.5 h-3.5 w-3.5" />
{t("admin.notificationTestSend")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => reminderCheck.mutate()}
disabled={reminderCheck.isPending}
>
{reminderCheck.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
)}
<Activity className="mr-1.5 h-3.5 w-3.5" />
{t("admin.reminderCheckRun")}
</Button>
<Button
size="sm"
onClick={() => {
updateSettings.mutate(
{
...(reminderLateDraft != null && {
reminderLateMinutes: reminderLateDraft,
}),
...(reminderMissedDraft != null && {
reminderMissedMinutes: reminderMissedDraft,
}),
},
{
onSuccess: () => {
setReminderLateDraft(null);
setReminderMissedDraft(null);
},
},
);
}}
disabled={
updateSettings.isPending ||
(reminderLateDraft == null && reminderMissedDraft == null)
}
>
{updateSettings.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
)}
{t("common.save")}
</Button>
</div>
{testNotification.data?.results &&
testNotification.data.results.length > 0 && (
<div className="mt-4 space-y-1">
{testNotification.data.results.map((r, i) => (
<div key={i} className="flex items-center gap-1.5 text-xs">
{r.success ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-green-400" />
) : (
<XCircle className="h-3.5 w-3.5 shrink-0 text-red-400" />
)}
<span className="font-medium">{r.channel}</span>
{r.error && (
<span className="text-muted-foreground">— {r.error}</span>
)}
</div>
))}
</div>
)}
{reminderCheck.data?.medications &&
reminderCheck.data.medications.length > 0 && (
<div className="mt-4 space-y-2">
<h4 className="text-sm font-medium">
{t("admin.reminderCheckResults")}
</h4>
<div className="space-y-2">
{reminderCheck.data.medications.map((med, i) => (
<div key={i} className="bg-muted/50 space-y-1.5 rounded-lg p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{med.name} ({med.dose})
</span>
<Badge
variant={
med.notificationsEnabled ? "default" : "secondary"
}
>
{med.notificationsEnabled
? t("admin.reminderCheckNotifOn")
: t("admin.reminderCheckNotifOff")}
</Badge>
</div>
<p className="text-muted-foreground text-xs">
{med.user} — {med.dayOfWeek} {med.localTime} —{" "}
{t("admin.reminderCheckEventsToday")}: {med.eventsToday}
</p>
{med.schedules.map((sched, j) => {
const statusColor =
sched.status === "open"
? "text-green-400"
: sched.status === "threshold"
? "text-yellow-400"
: sched.status === "missed"
? "text-red-400"
: sched.status === "skipped"
? "text-muted-foreground"
: "";
return (
<div key={j} className="flex items-start gap-1.5 text-xs">
<span className="text-muted-foreground shrink-0">
{sched.window}
</span>
<span className="text-muted-foreground shrink-0">
[{sched.days}]
</span>
<span className={statusColor}>{sched.label}</span>
{sched.notificationSent && (
<span className="flex shrink-0 items-center gap-0.5 text-green-400">
<Bell className="h-3 w-3" />
{t("admin.reminderCheckNotifSent")}
</span>
)}
</div>
);
})}
</div>
))}
</div>
</div>
)}
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { Bell, Globe, Key, MessageCircle } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
export function ServicesSection({ id }: { id: string }) {
const { t } = useTranslations();
const { data: settings } = useAdminSettings();
const updateSettings = useUpdateSettings();
return (
<div
id={id}
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
>
<div className="flex items-center gap-2">
<Globe className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("admin.servicesGlobal")}</h2>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.servicesGlobalDescription")}
</p>
<div className="mt-4 space-y-3">
<SettingsToggle
label="Telegram"
description={t("admin.telegramGlobal")}
icon={MessageCircle}
checked={settings?.telegramGlobal ?? true}
onCheckedChange={(checked) =>
updateSettings.mutate({ telegramGlobal: checked })
}
disabled={updateSettings.isPending}
/>
<SettingsToggle
label="ntfy"
description={t("admin.ntfyGlobal")}
icon={Bell}
checked={settings?.ntfyGlobal ?? true}
onCheckedChange={(checked) =>
updateSettings.mutate({ ntfyGlobal: checked })
}
disabled={updateSettings.isPending}
/>
<SettingsToggle
label={t("admin.integrationWebPush")}
description={t("admin.webPushGlobal")}
icon={Globe}
checked={settings?.webPushGlobal ?? true}
onCheckedChange={(checked) =>
updateSettings.mutate({ webPushGlobal: checked })
}
disabled={updateSettings.isPending}
/>
<SettingsToggle
label="API"
description={t("admin.apiGlobal")}
icon={Key}
checked={settings?.apiGlobal ?? true}
onCheckedChange={(checked) =>
updateSettings.mutate({ apiGlobal: checked })
}
disabled={updateSettings.isPending}
/>
</div>
</div>
);
}
@@ -0,0 +1,155 @@
"use client";
import {
Activity,
AlertTriangle,
Bell,
BellRing,
Bug,
Clock,
Cog,
Database,
Globe,
Key,
Loader2,
Server,
Users,
} from "lucide-react";
import { formatDateTime } from "@/lib/format";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { StatusItem, useSystemStatus } from "./_shared";
export function SystemStatusSection({ id }: { id: string }) {
const { t } = useTranslations();
const fmt = useFormatters();
const { data: status } = useSystemStatus();
return (
<div
id={id}
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
>
<div className="flex items-center gap-2">
<Server className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("admin.systemStatus")}</h2>
</div>
{status ? (
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatusItem
icon={Database}
label={t("admin.database")}
value={
status.database === "connected"
? t("admin.databaseConnected")
: t("admin.databaseError")
}
className={
status.database === "connected"
? "text-dracula-green"
: "text-destructive"
}
/>
<StatusItem
icon={Clock}
label={t("admin.startedAt")}
value={formatDateTime(status.startTime)}
/>
<StatusItem
icon={Users}
label={t("admin.users")}
value={String(status.counts.users)}
/>
<StatusItem
icon={Activity}
label={t("admin.measurementsCount")}
value={fmt.integer(status.counts.measurements)}
/>
<StatusItem
icon={Key}
label={t("admin.activeTokens")}
value={String(status.counts.activeTokens)}
/>
<StatusItem
icon={Globe}
label={t("admin.activeSessions")}
value={String(status.counts.activeSessions)}
/>
<StatusItem
icon={Cog}
label={t("admin.workerStatus")}
value={
status.worker.running
? t("admin.workerRunning")
: t("admin.workerStopped")
}
className={
status.worker.running ? "text-dracula-green" : "text-destructive"
}
/>
{status.worker.lastReminderCheck && (
<StatusItem
icon={Bell}
label={t("admin.lastReminderCheck")}
value={formatDateTime(status.worker.lastReminderCheck)}
/>
)}
{status.integrations.umami && (
<StatusItem
icon={Activity}
label="Umami"
value={
status.integrations.umami.enabled
? t("common.active")
: t("common.disabled")
}
className={
status.integrations.umami.enabled
? "text-dracula-green"
: "text-destructive"
}
/>
)}
{status.integrations.glitchtip && (
<StatusItem
icon={AlertTriangle}
label="GlitchTip"
value={
status.integrations.glitchtip.enabled
? t("common.active")
: t("common.disabled")
}
className={
status.integrations.glitchtip.enabled
? "text-dracula-green"
: "text-destructive"
}
/>
)}
{status.integrations.webPush && (
<StatusItem
icon={BellRing}
label={t("admin.integrationWebPush")}
value={t("admin.configured")}
className="text-dracula-green"
/>
)}
{status.integrations.bugReport && (
<StatusItem
icon={Bug}
label={t("admin.integrationBugReport")}
value={t("admin.configured")}
className="text-dracula-green"
/>
)}
</div>
) : (
<div className="mt-4 flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<span className="text-muted-foreground text-sm">
{t("admin.loadingStatus")}
</span>
</div>
)}
</div>
);
}
+173
View File
@@ -0,0 +1,173 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Activity, Loader2 } from "lucide-react";
import { toast } from "sonner";
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 { useTranslations } from "@/lib/i18n/context";
import {
SettingsToggle,
getApiErrorMessage,
useAdminSettings,
useUpdateSettings,
} from "./_shared";
export function UmamiSection({ id }: { id: string }) {
const { t } = useTranslations();
const { data: settings } = useAdminSettings();
const updateSettings = useUpdateSettings();
const [umamiScriptUrlDraft, setUmamiScriptUrlDraft] = useState<string | null>(
null,
);
const [umamiWebsiteIdDraft, setUmamiWebsiteIdDraft] = useState<string | null>(
null,
);
const umamiScriptUrlValue =
umamiScriptUrlDraft ?? settings?.umamiScriptUrl ?? "";
const umamiWebsiteIdValue =
umamiWebsiteIdDraft ?? settings?.umamiWebsiteId ?? "";
const configured = Boolean(
settings?.umamiScriptUrl && settings?.umamiWebsiteId,
);
const testUmami = useMutation({
mutationFn: async () => {
const res = await fetch("/api/admin/monitoring/umami-test", {
method: "POST",
});
if (!res.ok) {
throw new Error(await getApiErrorMessage(res));
}
const json = (await res.json()) as { data?: { message?: string } };
return json.data?.message ?? t("admin.monitoringTestSuccess");
},
onSuccess: (message) => {
toast.success(message);
},
onError: (error) => {
toast.error(
error instanceof Error
? error.message
: t("admin.monitoringTestFailed"),
);
},
});
function saveUmamiSettings() {
updateSettings.mutate(
{
umamiScriptUrl: umamiScriptUrlValue,
umamiWebsiteId: umamiWebsiteIdValue,
},
{
onSuccess: () => {
setUmamiScriptUrlDraft(null);
setUmamiWebsiteIdDraft(null);
},
},
);
}
return (
<div
id={id}
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Activity className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("admin.umamiTitle")}</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{configured && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("admin.configured")}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.umamiDescription")}
</p>
<div className="mt-4 space-y-3">
<SettingsToggle
label="Umami"
description={t("admin.umamiEnabled")}
icon={Activity}
checked={settings?.umamiEnabled ?? false}
onCheckedChange={(checked) =>
updateSettings.mutate({ umamiEnabled: checked })
}
disabled={updateSettings.isPending}
/>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="admin-umami-script-url" className="text-xs">
{t("admin.umamiScriptUrl")}
</Label>
<Input
id="admin-umami-script-url"
name="admin-umami-script-url"
value={umamiScriptUrlValue}
onChange={(event) => setUmamiScriptUrlDraft(event.target.value)}
placeholder={t("admin.umamiScriptUrlPlaceholder")}
autoComplete="new-password"
spellCheck={false}
data-lpignore="true"
data-1p-ignore="true"
disabled={updateSettings.isPending}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="admin-umami-website-id" className="text-xs">
{t("admin.umamiWebsiteId")}
</Label>
<Input
id="admin-umami-website-id"
name="admin-umami-website-id"
value={umamiWebsiteIdValue}
onChange={(event) => setUmamiWebsiteIdDraft(event.target.value)}
placeholder={t("admin.umamiWebsiteIdPlaceholder")}
autoComplete="new-password"
spellCheck={false}
data-lpignore="true"
data-1p-ignore="true"
disabled={updateSettings.isPending}
/>
</div>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => testUmami.mutate()}
disabled={testUmami.isPending || updateSettings.isPending}
>
{testUmami.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
)}
{t("common.test")}
</Button>
<Button
size="sm"
onClick={saveUmamiSettings}
disabled={updateSettings.isPending}
>
{updateSettings.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
)}
{t("common.save")}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,341 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { KeyRound, Loader2, Pencil, Shield, Users } from "lucide-react";
import { toast } from "sonner";
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 { PasswordStrength } from "@/components/ui/password-strength";
import { formatDate } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { type AdminUser, PasswordInput } from "./_shared";
export function UserManagementSection({
id,
currentUserId,
}: {
id: string;
currentUserId: string;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
const [editUsername, setEditUsername] = useState("");
const [editEmail, setEditEmail] = useState("");
const [resetUser, setResetUser] = useState<AdminUser | null>(null);
const [resetPassword, setResetPassword] = useState("");
const [resetMsg, setResetMsg] = useState<string | null>(null);
const { data: users } = useQuery({
queryKey: ["admin", "users"],
queryFn: async () => {
const res = await fetch("/api/admin/users");
if (!res.ok) throw new Error("Failed");
return (await res.json()).data as AdminUser[];
},
});
const updateUser = useMutation({
mutationFn: async ({
id,
data,
}: {
id: string;
data: Record<string, unknown>;
}) => {
const res = await fetch(`/api/admin/users/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const json = await res.json();
if (!res.ok) throw new Error(json.error || t("common.error"));
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
setEditingUser(null);
toast.success(t("common.saved"));
},
onError: (err) => {
toast.error(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
);
},
});
const resetPw = useMutation({
mutationFn: async ({ id, password }: { id: string; password: string }) => {
const res = await fetch(`/api/admin/users/${id}/reset-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
const json = await res.json();
if (!res.ok) throw new Error(json.error || t("common.error"));
},
onSuccess: () => {
setResetMsg(t("admin.passwordReset"));
setResetPassword("");
},
onError: (err: Error) => {
setResetMsg(err.message);
},
});
function startEdit(u: AdminUser) {
setEditingUser(u);
setEditUsername(u.username);
setEditEmail(u.email ?? "");
}
function startReset(u: AdminUser) {
setResetUser(u);
setResetPassword("");
setResetMsg(null);
}
return (
<div
id={id}
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
>
<div className="flex items-center gap-2">
<Users className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("admin.userManagement")}</h2>
{users && (
<Badge variant="secondary" className="text-xs">
{users.length}
</Badge>
)}
</div>
{users ? (
<div className="mt-4 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("admin.users")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.userEmail")}
</th>
<th className="px-3 py-2 text-center font-medium">
{t("admin.userRole")}
</th>
<th className="px-3 py-2 text-center font-medium">
{t("admin.userPasskeys")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.userCreated")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.userActions")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{users.map((u, i) => (
<tr key={u.id} className={i % 2 === 0 ? "bg-muted/30" : ""}>
<td className="px-3 py-2 font-medium">{u.username}</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{u.email || "—"}
</td>
<td className="px-3 py-2 text-center">
<Badge
variant={u.role === "ADMIN" ? "default" : "secondary"}
className="text-xs"
>
{u.role}
</Badge>
</td>
<td className="px-3 py-2 text-center">{u.passkeyCount}</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
{formatDate(u.createdAt)}
</td>
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() =>
updateUser.mutate({
id: u.id,
data: {
role: u.role === "ADMIN" ? "USER" : "ADMIN",
},
})
}
disabled={u.id === currentUserId}
title={
u.id === currentUserId
? t("admin.ownRoleUnchangeable")
: u.role === "ADMIN"
? t("admin.demoteToUser")
: t("admin.promoteToAdmin")
}
>
<Shield className="mr-1 h-3 w-3" />
{u.role === "ADMIN"
? t("admin.toUser")
: t("admin.toAdmin")}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => startEdit(u)}
>
<Pencil className="mr-1 h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => startReset(u)}
>
<KeyRound className="mr-1 h-3 w-3" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="mt-4 flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<span className="text-muted-foreground text-sm">
{t("admin.loadingUsers")}
</span>
</div>
)}
{/* Edit Dialog */}
{editingUser && (
<div className="bg-muted/80 mt-4 rounded-lg p-4">
<h3 className="mb-3 text-sm font-medium">
{t("admin.editUserTitle", { name: editingUser.username })}
</h3>
<div className="space-y-3">
<div className="space-y-1">
<Label htmlFor="edit-username">{t("auth.username")}</Label>
<Input
id="edit-username"
value={editUsername}
onChange={(e) => setEditUsername(e.target.value)}
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
/>
</div>
<div className="space-y-1">
<Label htmlFor="edit-email">{t("admin.userEmail")}</Label>
<Input
id="edit-email"
type="email"
value={editEmail}
onChange={(e) => setEditEmail(e.target.value)}
placeholder={t("common.optional")}
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
/>
</div>
<div className="flex gap-2">
<Button
size="sm"
disabled={updateUser.isPending}
onClick={() =>
updateUser.mutate({
id: editingUser.id,
data: {
username: editUsername,
email: editEmail || null,
},
})
}
>
{updateUser.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
)}
{t("common.save")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setEditingUser(null)}
>
{t("common.cancel")}
</Button>
{updateUser.isError && (
<span className="text-destructive self-center text-sm">
{(updateUser.error as Error).message}
</span>
)}
</div>
</div>
</div>
)}
{/* Password Reset Dialog */}
{resetUser && (
<div className="bg-muted/80 mt-4 rounded-lg p-4">
<h3 className="mb-3 text-sm font-medium">
{t("admin.resetPasswordTitle", { name: resetUser.username })}
</h3>
<div className="space-y-3">
<div className="space-y-1">
<Label htmlFor="reset-pw">{t("admin.newPassword")}</Label>
<PasswordInput
id="reset-pw"
value={resetPassword}
onChange={(e) => setResetPassword(e.target.value)}
placeholder={t("admin.newPasswordPlaceholder")}
/>
<PasswordStrength password={resetPassword} />
</div>
<div className="flex gap-2">
<Button
size="sm"
disabled={resetPw.isPending || !resetPassword}
onClick={() =>
resetPw.mutate({
id: resetUser.id,
password: resetPassword,
})
}
>
{resetPw.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
)}
{t("admin.reset")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setResetUser(null)}
>
{t("common.cancel")}
</Button>
</div>
{resetMsg && (
<p
className={`text-sm ${resetMsg === t("admin.passwordReset") ? "text-dracula-green" : "text-destructive"}`}
>
{resetMsg}
</p>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,148 @@
"use client";
import { useState } from "react";
import { BellRing, Loader2 } 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 { useTranslations } from "@/lib/i18n/context";
import { PasswordInput, useAdminSettings, useUpdateSettings } from "./_shared";
export function WebPushVapidSection({ id }: { id: string }) {
const { t } = useTranslations();
const { data: settings } = useAdminSettings();
const updateSettings = useUpdateSettings();
const [webPushVapidPublicKeyDraft, setWebPushVapidPublicKeyDraft] = useState<
string | null
>(null);
const [webPushVapidPrivateKeyDraft, setWebPushVapidPrivateKeyDraft] =
useState("");
const [webPushVapidSubjectDraft, setWebPushVapidSubjectDraft] = useState<
string | null
>(null);
const webPushVapidPublicKeyValue =
webPushVapidPublicKeyDraft ?? settings?.webPushVapidPublicKey ?? "";
const webPushVapidSubjectValue =
webPushVapidSubjectDraft ?? settings?.webPushVapidSubject ?? "";
const configured = settings?.webPushVapidConfigured ?? false;
function saveWebPushVapidSettings() {
const payload: Record<string, unknown> = {
webPushVapidPublicKey: webPushVapidPublicKeyValue,
webPushVapidSubject: webPushVapidSubjectValue,
};
if (webPushVapidPrivateKeyDraft.trim().length > 0) {
payload.webPushVapidPrivateKey = webPushVapidPrivateKeyDraft.trim();
}
updateSettings.mutate(payload, {
onSuccess: () => {
setWebPushVapidPublicKeyDraft(null);
setWebPushVapidPrivateKeyDraft("");
setWebPushVapidSubjectDraft(null);
},
});
}
return (
<div
id={id}
className="bg-card border-border scroll-mt-28 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("admin.webPushVapidTitle")}
</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{configured && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
{t("admin.configured")}
</Badge>
)}
</div>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.webPushVapidDescription")}
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="admin-web-push-public-key" className="text-xs">
{t("admin.webPushVapidPublicKey")}
</Label>
<Input
id="admin-web-push-public-key"
name="admin-web-push-public-key"
value={webPushVapidPublicKeyValue}
onChange={(event) =>
setWebPushVapidPublicKeyDraft(event.target.value)
}
placeholder={t("admin.webPushVapidPublicKeyPlaceholder")}
autoComplete="new-password"
spellCheck={false}
data-lpignore="true"
data-1p-ignore="true"
disabled={updateSettings.isPending}
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="admin-web-push-private-key" className="text-xs">
{t("admin.webPushVapidPrivateKey")}
</Label>
<PasswordInput
id="admin-web-push-private-key"
name="admin-web-push-private-key"
value={webPushVapidPrivateKeyDraft}
onChange={(event) =>
setWebPushVapidPrivateKeyDraft(event.target.value)
}
placeholder={t("admin.webPushVapidPrivateKeyPlaceholder")}
autoComplete="new-password"
spellCheck={false}
data-lpignore="true"
data-1p-ignore="true"
disabled={updateSettings.isPending}
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="admin-web-push-subject" className="text-xs">
{t("admin.webPushVapidSubject")}
</Label>
<Input
id="admin-web-push-subject"
name="admin-web-push-subject"
value={webPushVapidSubjectValue}
onChange={(event) =>
setWebPushVapidSubjectDraft(event.target.value)
}
placeholder={t("admin.webPushVapidSubjectPlaceholder")}
autoComplete="new-password"
spellCheck={false}
data-lpignore="true"
data-1p-ignore="true"
disabled={updateSettings.isPending}
/>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
size="sm"
onClick={saveWebPushVapidSettings}
disabled={updateSettings.isPending}
>
{updateSettings.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
)}
{t("common.save")}
</Button>
</div>
</div>
);
}
+1 -1
View File
@@ -130,7 +130,7 @@ ADVANCED METRICS:
- moodAdherenceRisk: mood ≤ 2.5 over 7 days and falling = adherence drop within the next 5 days likely. Address proactively.
- seasonalVariation: winter-summer delta of systolic BP. > 5 mmHg is physiologically normal. Reassure the user — this is not a deterioration.
- sleep: target ≥ 7h/night (AASM 2015 Adult Sleep Duration Consensus). < 6h: risk factor for hypertension and weight gain.
- activity: ≥ 8,000 steps/day (Saint-Maurice et al., JAMA 2020 — mortality plateau 8,000–12,000). Note: WHO publishes activity *time* (150–300 min/week moderate), NOT a step quota — do not cite "WHO" as the source of a step number.
- activity: ≥ 8,000 steps/day (Saint-Maurice et al., JAMA 2020 — continued dose-response benefit through ~12,000 steps/day, not a plateau). Note: WHO publishes activity *time* (150–300 min/week moderate), NOT a step quota — do not cite "WHO" as the source of a step number.
HISTORICAL COMPARISON:
- Compare current 7-day values against the previous 30-day average.
+1 -1
View File
@@ -31,7 +31,7 @@ const GENERAL_SECTION_EN = `DOMAIN — OVERALL ASSESSMENT:
- Use historicalComparison to anchor current changes against the established baseline.
- If age and sex are known, apply age- and sex-specific risk assessment.
- Sleep: When sleep data is present, fold sleep quality into the overall picture. < 6h/night = risk factor for hypertension and weight gain.
- Activity: When activity data is present, evaluate step count (≥ 8,000/day — Saint-Maurice et al., JAMA 2020; mortality plateau 8,000–12,000). Note: WHO publishes activity *time* (min/week), NOT a step quota — do not phrase the target as "WHO ≥ 8,000 steps". Tie back to pulse and weight trends.
- Activity: When activity data is present, evaluate step count (≥ 8,000/day — Saint-Maurice et al., JAMA 2020; benefit accumulates through ~12,000 steps/day, not a plateau). Note: WHO publishes activity *time* (min/week), NOT a step quota — do not phrase the target as "WHO ≥ 8,000 steps". Tie back to pulse and weight trends.
- Rate-pressure product: When ratePressureProduct is present, include it as a cardiac-load indicator. > 12,000 = elevated myocardial oxygen demand.
- Body-composition divergence: If bodyCompositionDivergence.flag = true, treat as an early sign of sarcopenic obesity.
- Seasonal variation: When seasonalVariation is present, contextualise seasonal BP swings and reassure where appropriate.
+11 -8
View File
@@ -46,7 +46,7 @@ export function classifyBMI(bmi: number): BmiClassification {
};
}
// ── BP Classification (ESC/ESH 2018) ────────────────────
// ── BP Classification (ESH 2023) ────────────────────────
export interface BpClassification {
category: string;
@@ -244,9 +244,10 @@ export function classifyBodyFat(
// Cohort source: Saint-Maurice PF, et al. "Association of daily step
// count and step intensity with mortality among US adults." JAMA. 2020.
// https://jamanetwork.com/journals/jama/fullarticle/2763292
// Mortality benefit plateaus 8 000–12 000 steps/day. WHO publishes
// physical-activity TIME (150–300 min/wk moderate) — NOT a step
// quota; do not cite "WHO ≥ 8 000 steps".
// Mortality benefit accumulates strongly through ~12 000 steps/day
// (HR 0.49 at 8k vs 4k, HR 0.35 at 12k — continued dose-response,
// not a plateau). WHO publishes physical-activity TIME (150–300
// min/wk moderate) — NOT a step quota; do not cite "WHO ≥ 8 000 steps".
export interface StepsClassification {
category: string;
@@ -280,8 +281,8 @@ export function classifySteps(steps: number): StepsClassification {
/**
* Activity-steps target range. Single source of truth: aligned with
* `effective-range.ts` (Saint-Maurice JAMA 2020 — mortality plateau
* 8 000–12 000). The v1.3.3 audit flagged a drift where this helper
* `effective-range.ts` (Saint-Maurice JAMA 2020 — continued mortality
* dose-response through ~12 000 steps/day). The v1.3.3 audit flagged a drift where this helper
* returned {7 000, 10 000} while `effective-range.ts` returned
* {8 000, 15 000}; both surfaces showed different "green" bands to the
* same user. They now agree.
@@ -319,7 +320,9 @@ export function getSleepDurationRange(): { min: number; max: number } {
}
/**
* BP target ranges based on ESC/ESH 2018 guidelines.
* BP target ranges based on ESH 2023 guidelines (numerically
* unchanged from the joint ESC/ESH 2018 document; the 2023 update
* is ESH-only since ESC withdrew from joint authoring).
*/
export function getBpTargetsByAge(
age: number,
@@ -329,7 +332,7 @@ export function getBpTargetsByAge(
if (age < 65) {
return { sysLow: 120, sysHigh: 129, diaLow: 70, diaHigh: 79 };
}
// 65+ (both 65–79 and ≥80 have the same targets per ESC/ESH)
// 65+ (both 65–79 and ≥80 have the same targets per ESH 2023)
return { sysLow: 130, sysHigh: 139, diaLow: 70, diaHigh: 79 };
}
+3 -2
View File
@@ -207,8 +207,9 @@ function defaultRange(
// AASM: 7–9h for adults; warning yellow either side.
return { greenMin: 7, greenMax: 9, orangeMin: 6, orangeMax: 10 };
case "ACTIVITY_STEPS":
// ≥8000 steps/day per Saint-Maurice et al., JAMA 2020 (mortality
// plateau 8000–12000 steps). WHO 2020 PA guidelines publish minutes
// ≥8000 steps/day per Saint-Maurice et al., JAMA 2020 (continued
// dose-response benefit through ~12000 steps/day, not a plateau —
// HR 0.49 at 8k vs 4k, HR 0.35 at 12k). WHO 2020 PA guidelines publish minutes
// per week (150–300 min moderate / 75–150 min vigorous) — *not* a
// step quota. No upper bound in reality, orange over 25k caps edge
// detection.
+12 -3
View File
@@ -4,6 +4,7 @@ import type { User } from "@/generated/prisma/client";
import { WideEventBuilder } from "./logging/event-builder";
import { eventStorage, getEvent } from "./logging/context";
import { emitIfSampled } from "./logging/transports";
import { redactOptional, redactSecrets } from "./logging/redact";
import { getSession } from "./auth/session";
import { hashToken } from "./auth/hmac";
import { prisma } from "./db";
@@ -92,7 +93,11 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
} finally {
const status = (response as Response | undefined)?.status ?? 500;
evt.finish(status);
try { emitIfSampled(evt.toJSON()); } catch { /* logging must never crash the handler */ }
try {
emitIfSampled(evt.toJSON());
} catch {
/* logging must never crash the handler */
}
}
const nr = response as NextResponse;
nr.headers.set("x-request-id", evt.getRequestId());
@@ -345,10 +350,14 @@ async function reportToGlitchtip(
dsn: settings.glitchtipDsn,
input: {
environment: settings.glitchtipEnvironment || "production",
message: err.message,
// Defence in depth: even though the WideEventBuilder already
// redacts on `setError()`, the GlitchTip path imports `err`
// directly. Apply the same redaction here so a Telegram bot
// token or external Bearer cannot leak via the incident UI.
message: redactSecrets(err.message),
level: "error",
type: err.name || "Error",
stack: err.stack,
stack: redactOptional(err.stack),
url: scrubbedUrl,
sourceTag: "healthlog-api-handler",
requestId: evt.getRequestId(),
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { redactSecrets, redactOptional } from "../redact";
describe("redactSecrets", () => {
it("redacts Bearer tokens", () => {
expect(redactSecrets("Authorization: Bearer hlk_abc123def456")).toBe(
"Authorization: Bearer [REDACTED]",
);
expect(redactSecrets("auth=Bearer whitespace_token")).toBe(
"auth=Bearer [REDACTED]",
);
});
it("redacts Telegram bot tokens in path-style URLs", () => {
expect(
redactSecrets(
"fetch failed: https://api.telegram.org/bot1234567890:AAEhBP-w-secret/sendMessage",
),
).toBe("fetch failed: https://api.telegram.org/bot[REDACTED]/sendMessage");
});
it("redacts query-string secrets", () => {
expect(redactSecrets("Withings webhook ?secret=abc123 failed")).toBe(
"Withings webhook ?secret=[REDACTED] failed",
);
expect(redactSecrets("OAuth ?code=xyz&state=abc")).toBe(
"OAuth ?code=[REDACTED]&state=abc",
);
expect(redactSecrets("api?api_key=secret")).toBe("api?api_key=[REDACTED]");
});
it("leaves non-secret-shaped strings alone", () => {
expect(redactSecrets("HTTP 503 from upstream")).toBe(
"HTTP 503 from upstream",
);
expect(redactSecrets("user-7 measurement saved")).toBe(
"user-7 measurement saved",
);
});
it("handles multiple secrets in one string", () => {
// Bearer matches `\S+` so it greedily consumes `hlk_x;` — that's
// safe (over-redaction is fine when the alternative is leaking).
expect(redactSecrets("Bearer hlk_x bot999:tok ?code=abc123")).toBe(
"Bearer [REDACTED] bot[REDACTED] ?code=[REDACTED]",
);
});
});
describe("redactOptional", () => {
it("returns undefined for undefined input", () => {
expect(redactOptional(undefined)).toBeUndefined();
});
it("redacts when present", () => {
expect(redactOptional("Bearer x")).toBe("Bearer [REDACTED]");
});
});
+6 -4
View File
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import type { WideEvent, LogLevel, EventKind } from "./types";
import { LOG_LEVEL_PRIORITY } from "./types";
import { getDeployContext } from "./config";
import { redactOptional, redactSecrets } from "./redact";
/**
* Baut ein Wide Event Schritt fuer Schritt auf.
@@ -61,14 +62,14 @@ export class WideEventBuilder {
if (err instanceof Error) {
this.event.error = {
type: err.constructor.name,
message: err.message,
stack: err.stack,
message: redactSecrets(err.message),
stack: redactOptional(err.stack),
code: (err as { statusCode?: number }).statusCode,
};
} else {
this.event.error = {
type: "Unknown",
message: String(err),
message: redactSecrets(String(err)),
};
}
return this;
@@ -88,7 +89,8 @@ export class WideEventBuilder {
}
addDbQuery(durationMs: number): this {
if (!this.event.db) this.event.db = { query_count: 0, query_duration_ms: 0 };
if (!this.event.db)
this.event.db = { query_count: 0, query_duration_ms: 0 };
this.event.db.query_count++;
this.event.db.query_duration_ms += durationMs;
return this;
+35
View File
@@ -0,0 +1,35 @@
/**
* Redact secrets from strings before they leave the process boundary
* (Loki, Glitchtip, console). Applied in `WideEventBuilder.setError()`
* and `reportToGlitchtip()` so every error reaching observability is
* scrubbed once, centrally.
*
* Patterns:
* - `Bearer <token>` — generic API tokens incl. our `hlk_` /
* `hlr_` formats and external OAuth bearers (Withings, AI providers).
* - `bot<digits>:<token>` — Telegram bot URLs embed the token in the
* path: `https://api.telegram.org/bot1234567:ABC-…/sendMessage`.
* If `fetch()` rejects with a `cause` that surfaces the URL (some
* Node runtimes), the token would land in error reports.
* - `?secret=…` and `?code=…` — query-string leaks for legacy
* Withings webhooks and OAuth callbacks (already scrubbed at the
* URL level in `reportToGlitchtip`, but error.message can carry
* the URL too).
*
* The substitution is intentionally generic ([REDACTED]) — we don't
* want partial revelation of token entropy.
*/
export function redactSecrets(input: string): string {
return input
.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]")
.replace(/bot\d+:[A-Za-z0-9_-]+/g, "bot[REDACTED]")
.replace(
/([?&])(secret|code|token|api[_-]?key)=[^&\s]+/gi,
"$1$2=[REDACTED]",
);
}
/** Apply `redactSecrets` to a possibly-undefined string. */
export function redactOptional(s: string | undefined): string | undefined {
return s === undefined ? undefined : redactSecrets(s);
}
+7 -4
View File
@@ -52,9 +52,12 @@ export const CITATIONS = {
/**
* Saint-Maurice PF, et al. "Association of daily step count and step
* intensity with mortality among US adults." JAMA. 2020.
* Mortality benefit plateaus 8 000–12 000 steps/day. WHO 2020 PA
* guidelines publish minutes per week, NOT a step quota — DO NOT
* cite WHO for a step number.
* The paper reports continued dose-response benefit (HR 0.49 at 8k
* vs 4k, HR 0.35 at 12k vs 4k) — NOT a plateau. The plateau-shaped
* finding belongs to Paluch 2022 Lancet Public Health (PMID
* 35247352, age-stratified meta-analysis). WHO 2020 PA guidelines
* publish minutes per week, NOT a step quota — DO NOT cite WHO
* for a step number.
*/
STEPS_SAINT_MAURICE_2020: {
id: "steps-saint-maurice-2020",
@@ -62,7 +65,7 @@ export const CITATIONS = {
year: 2020,
url: "https://jamanetwork.com/journals/jama/fullarticle/2763292",
caveat:
"U.S. adult cohort, observational; benefit plateaus 8–12k steps/day.",
"U.S. adult cohort, observational; benefit accumulates strongly through ~12k steps/day (continued dose-response, not a plateau).",
},
/**
+28 -1
View File
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { getEvent } from "@/lib/logging/context";
import { isPublicUrl } from "@/lib/validations/notifications";
/**
* Sync mood entries from a user's moodLog instance.
@@ -32,6 +33,18 @@ export async function syncMoodLogEntries(
const baseUrl = decrypt(user.moodLogUrlEncrypted);
const apiKey = decrypt(user.moodLogApiKeyEncrypted);
// SSRF guard at the actual fetch site. The credential write path
// is also guarded (moodLogCredentialsSchema), but a row stored
// before that guard landed could still point at an internal IP.
// Re-checking here means the sync worker refuses internal targets
// even on legacy data, and the user's apiKey is never sent there.
if (!isPublicUrl(baseUrl)) {
getEvent()?.addWarning(
`moodLog sync refused for user ${userId}: stored URL points at non-public host`,
);
return 0;
}
// 2. Determine date range
const now = new Date();
const to = now.toISOString().slice(0, 10); // YYYY-MM-DD
@@ -61,6 +74,10 @@ export async function syncMoodLogEntries(
response = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${apiKey}` },
signal: controller.signal,
// SSRF defence-in-depth: do NOT follow redirects. A public
// host that 302s to an RFC1918 target would otherwise leak
// the apiKey to the internal hop.
redirect: "manual",
});
} finally {
clearTimeout(timeout);
@@ -71,8 +88,18 @@ export async function syncMoodLogEntries(
});
}
// Treat any 3xx as failure (manual redirect mode surfaces them).
if (response.status >= 300 && response.status < 400) {
getEvent()?.addWarning(
`moodLog sync refused redirect for user ${userId}: HTTP ${response.status}`,
);
return 0;
}
if (!response.ok) {
getEvent()?.addWarning(`Sync failed for user ${userId}: HTTP ${response.status}`);
getEvent()?.addWarning(
`Sync failed for user ${userId}: HTTP ${response.status}`,
);
return 0;
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { moodLogCredentialsSchema } from "../moodlog";
describe("moodLogCredentialsSchema SSRF guard", () => {
it("accepts a public moodLog URL", () => {
const r = moodLogCredentialsSchema.safeParse({
url: "https://moodlog.app",
apiKey: "k".repeat(40),
});
expect(r.success).toBe(true);
});
it("rejects RFC1918 URLs", () => {
for (const url of [
"http://10.0.0.1",
"http://192.168.1.1",
"http://172.16.0.1",
"http://127.0.0.1",
]) {
const r = moodLogCredentialsSchema.safeParse({
url,
apiKey: "k".repeat(40),
});
expect(r.success, `expected reject for ${url}`).toBe(false);
}
});
it("rejects link-local (cloud metadata) URLs", () => {
const r = moodLogCredentialsSchema.safeParse({
url: "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
apiKey: "k".repeat(40),
});
expect(r.success).toBe(false);
});
it("rejects localhost", () => {
const r = moodLogCredentialsSchema.safeParse({
url: "http://localhost:8080",
apiKey: "k".repeat(40),
});
expect(r.success).toBe(false);
});
});
+13 -1
View File
@@ -1,7 +1,19 @@
import { z } from "zod/v4";
import { isPublicUrl } from "@/lib/validations/notifications";
export const moodLogCredentialsSchema = z.object({
url: z.string().url().max(500),
url: z
.string()
.url()
.max(500)
// SSRF guard: stored URL must point at a public host. The sync
// worker fetches from this URL with the user's apiKey in the
// Authorization header, so a stored RFC1918 / link-local target
// would let any user pull cloud-metadata or local-network data
// back through their account.
.refine((u) => isPublicUrl(u), {
message: "URL must point at a public host (no RFC1918 / link-local)",
}),
apiKey: z.string().min(1).max(200),
});