mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
1 parent
956d29032f
commit
643e6ec3a0
59 files changed
+7318
-3844
No files matched your search
+14
-2639
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user