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

Production at healthlog.bombeck.io has been 503-ing since the v1.4.1
deploys started landing on apps-01 (Coolify). The container boots —
Next.js prints "Ready" and the pg-boss workers run — but never
accepts HTTP on :3000, so the Docker healthcheck fails and Traefik
takes the upstream out of rotation. A manual restart, a Coolify
force-rebuild, and a docker-compose pin to the GHCR :1.4.0 multi-arch
image all failed to bring the site back up — Coolify rebuilds the
image from main HEAD on every deploy regardless of the compose
directives.

This commit resets the working tree to commit 21bd46d (v1.4.0
release). Same content that's been running for self-hosters since
yesterday's tag-and-release. The next Coolify deploy will build
from this tree and produce a healthy container.

The v1.4.1 work is NOT lost:
  - PRs #144, #145, #137, #146, #147, #148, #149, #150 remain in
    git history.
  - Their commits are still tagged (`v1.4.1`), still on the GHCR
    multi-arch image (`ghcr.io/mbombeck/healthlog:1.4.1`), still in
    the GitHub Release notes.
  - Self-hosters who have already pulled the v1.4.1 image keep it.
  - Local development continues from main HEAD with the v1.4.1
    code — the regression only surfaced under the Coolify build
    flow.

Re-applying v1.4.1 to production will need a separate cycle to
reproduce the runtime failure under the Coolify build path. That
work is tracked in docs/ops/v141-followup-issues.md (added back
when the tree is reapplied) and the deploy gating in
.github/workflows/e2e.yml will catch this class of bug going
forward.

No DB migration. No env-var change. No API contract change.
Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
This commit is contained in:
Marc-André Bombeck committed 2026-05-08 17:15:34 +02:00
1 parent e33be0d06e
commit e444a90b10
58 files changed
+3842 -7292

No files matched your search

-276
View File
@@ -1,276 +0,0 @@
"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;
},
});
}
@@ -1,144 +0,0 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, Key, Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { formatDate, formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { type ApiTokenInfo } from "./_shared";
export function ApiTokenOverviewSection({ id }: { id: string }) {
const { t } = useTranslations();
const [expanded, setExpanded] = useState(false);
const { data: tokens, isLoading } = useQuery({
queryKey: ["admin", "tokens"],
queryFn: async () => {
const res = await fetch("/api/admin/tokens");
if (!res.ok) throw new Error("Failed");
return (await res.json()).data as ApiTokenInfo[];
},
enabled: expanded,
});
return (
<div
id={id}
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Key className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("admin.apiTokens")}</h2>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
>
{expanded ? t("settings.collapse") : t("settings.expand")}
<ChevronDown
className={`ml-1 h-4 w-4 transition-transform ${expanded ? "rotate-180" : ""}`}
/>
</Button>
</div>
{expanded && (
<div className="mt-4">
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
</div>
) : !tokens?.length ? (
<p className="text-muted-foreground text-sm">
{t("admin.noTokens")}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("admin.tokenUser")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.tokenName")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.tokenPermissions")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.tokenStatus")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.tokenLastUsed")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.tokenCreated")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{tokens.map((token, i) => {
const isExpired =
token.expiresAt && new Date(token.expiresAt) < new Date();
return (
<tr
key={token.id}
className={i % 2 === 0 ? "bg-muted/30" : ""}
>
<td className="px-3 py-2 font-medium">
{token.user.username}
</td>
<td className="px-3 py-2">{token.name}</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-1">
{token.permissions.map((p) => (
<Badge
key={p}
variant="secondary"
className="text-xs"
>
{p}
</Badge>
))}
</div>
</td>
<td className="px-3 py-2 text-right">
{token.revoked ? (
<Badge variant="destructive" className="text-xs">
{t("settings.tokenRevoked")}
</Badge>
) : isExpired ? (
<Badge variant="destructive" className="text-xs">
{t("settings.tokenExpired")}
</Badge>
) : (
<Badge className="bg-dracula-green/15 text-dracula-green text-xs">
{t("common.active")}
</Badge>
)}
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
{token.lastUsedAt
? formatDateTime(token.lastUsedAt)
: t("admin.tokenNeverUsed")}
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
{formatDate(token.createdAt)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
}
-109
View File
@@ -1,109 +0,0 @@
"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>
);
}
@@ -1,118 +0,0 @@
"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>
);
}
@@ -1,511 +0,0 @@
"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>
);
}
@@ -1,54 +0,0 @@
"use client";
import { Settings } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
export function GeneralSettingsSection({ id }: { id: string }) {
const { t } = useTranslations();
const { data: settings } = useAdminSettings();
const updateSettings = useUpdateSettings();
return (
<div
id={id}
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
>
<div className="flex items-center gap-2">
<Settings className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("admin.appSettings")}</h2>
</div>
<div className="mt-4 space-y-4">
<SettingsToggle
label={t("admin.registrationEnabled")}
description={t("admin.registrationEnabledDescription")}
checked={settings?.registrationEnabled ?? true}
onCheckedChange={(checked) =>
updateSettings.mutate({ registrationEnabled: checked })
}
disabled={updateSettings.isPending}
/>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{t("admin.defaultLanguage")}</p>
<p className="text-muted-foreground text-xs">
{t("admin.defaultLanguageDescription")}
</p>
</div>
<select
value={settings?.defaultLocale ?? "de"}
onChange={(e) =>
updateSettings.mutate({ defaultLocale: e.target.value })
}
disabled={updateSettings.isPending}
className="border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-none"
>
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</div>
</div>
</div>
);
}
-172
View File
@@ -1,172 +0,0 @@
"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>
);
}
@@ -1,181 +0,0 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
CheckCircle2,
ChevronDown,
Loader2,
ScrollText,
XCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { type AdminAuditEntry } from "./_shared";
export function LoginOverviewSection({ id }: { id: string }) {
const { t } = useTranslations();
const [expanded, setExpanded] = useState(false);
const [filter, setFilter] = useState<"all" | "failed">("all");
const AUTH_ACTION_LABELS: Record<string, string> = {
"auth.register": t("admin.authRegister"),
"auth.login": t("admin.authLogin"),
"auth.login.passkey": t("admin.authLoginPasskey"),
"auth.login.password": t("admin.authLoginPassword"),
"auth.login.failed": t("admin.authLoginFailed"),
"auth.logout": t("admin.authLogout"),
"auth.passkey.register": t("admin.authPasskeyRegister"),
"auth.passkey.delete": t("admin.authPasskeyDelete"),
};
const { data, isLoading } = useQuery({
queryKey: ["admin", "audit-log", filter],
queryFn: async () => {
const res = await fetch(`/api/admin/audit-log?limit=100&filter=auth`);
if (!res.ok) throw new Error("Failed");
return (await res.json()).data as {
entries: AdminAuditEntry[];
meta: { total: number };
};
},
enabled: expanded,
});
const entries =
filter === "failed"
? data?.entries.filter((e) => e.action === "auth.login.failed")
: data?.entries;
return (
<div
id={id}
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<ScrollText className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("admin.loginOverview")}</h2>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
>
{expanded ? t("settings.collapse") : t("settings.expand")}
<ChevronDown
className={`ml-1 h-4 w-4 transition-transform ${expanded ? "rotate-180" : ""}`}
/>
</Button>
</div>
{expanded && (
<div className="mt-4 space-y-3">
<div className="flex gap-1">
<Button
variant={filter === "all" ? "default" : "ghost"}
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setFilter("all")}
>
{t("admin.allAuthEvents")}
</Button>
<Button
variant={filter === "failed" ? "default" : "ghost"}
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setFilter("failed")}
>
{t("admin.failedOnly")}
</Button>
</div>
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
</div>
) : !entries?.length ? (
<p className="text-muted-foreground text-sm">
{t("admin.noEntries")}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("admin.status")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.users")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.action")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.ip")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.location")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.timestamp")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{entries.map((entry, i) => {
const isFailed = entry.action === "auth.login.failed";
return (
<tr
key={entry.id}
className={i % 2 === 0 ? "bg-muted/30" : ""}
>
<td className="px-3 py-2">
{isFailed ? (
<XCircle className="text-destructive h-4 w-4" />
) : (
<CheckCircle2 className="text-dracula-green h-4 w-4" />
)}
</td>
<td
className={`px-3 py-2 font-medium ${isFailed ? "text-destructive" : ""}`}
>
{entry.user?.username ?? t("common.unknown")}
</td>
<td
className={`px-3 py-2 text-xs ${isFailed ? "text-destructive" : "text-muted-foreground"}`}
>
{AUTH_ACTION_LABELS[entry.action] ?? entry.action}
</td>
<td className="text-muted-foreground px-3 py-2 text-right font-mono text-xs">
{entry.ipAddress ?? "—"}
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs">
{entry.location ?? "—"}
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
{formatDateTime(entry.createdAt)}
</td>
</tr>
);
})}
</tbody>
</table>
{data && data.meta.total > entries.length && (
<p className="text-muted-foreground mt-3 text-center text-xs">
{t("admin.showingEntries", {
count: entries.length,
total: data.meta.total,
})}
</p>
)}
</div>
)}
</div>
)}
</div>
);
}
-318
View File
@@ -1,318 +0,0 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import {
Activity,
Bell,
CheckCircle2,
Clock,
Loader2,
XCircle,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import {
getApiErrorMessage,
useAdminSettings,
useUpdateSettings,
} from "./_shared";
export function RemindersSection({ id }: { id: string }) {
const { t } = useTranslations();
const { data: settings } = useAdminSettings();
const updateSettings = useUpdateSettings();
const [reminderLateDraft, setReminderLateDraft] = useState<number | null>(
null,
);
const [reminderMissedDraft, setReminderMissedDraft] = useState<number | null>(
null,
);
const testNotification = useMutation({
mutationFn: async () => {
const res = await fetch("/api/admin/notifications/test", {
method: "POST",
});
if (!res.ok) {
throw new Error(await getApiErrorMessage(res));
}
const json = (await res.json()) as {
data?: {
message?: string;
results?: Array<{
channel: string;
success: boolean;
error?: string;
}>;
};
};
return json.data;
},
onSuccess: (data) => {
const hasFailures = data?.results?.some((r) => !r.success);
if (hasFailures) {
toast.error(data?.message ?? t("admin.notificationTestFailed"));
} else {
toast.success(data?.message ?? t("admin.notificationTestSuccess"));
}
},
onError: (error) => {
toast.error(
error instanceof Error
? error.message
: t("admin.notificationTestFailed"),
);
},
});
const reminderCheck = useMutation({
mutationFn: async () => {
const res = await fetch("/api/admin/notifications/reminder-check", {
method: "POST",
});
if (!res.ok) {
throw new Error(await getApiErrorMessage(res));
}
const json = (await res.json()) as {
data?: {
message?: string;
medications?: Array<{
name: string;
dose: string;
user: string;
localTime: string;
dayOfWeek: string;
notificationsEnabled: boolean;
schedules: Array<{
window: string;
days: string;
status: string;
label: string;
notificationSent?: boolean;
}>;
eventsToday: number;
}>;
notificationsSent?: number;
};
};
return json.data;
},
onSuccess: (data) => {
toast.success(data?.message ?? t("admin.reminderCheckSuccess"));
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : t("admin.reminderCheckFailed"),
);
},
});
return (
<div
id={id}
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
>
<div className="flex items-center gap-2">
<Clock className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("admin.medicationReminders")}
</h2>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.medicationRemindersDescription")}
</p>
<div className="mt-4 space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="admin-reminder-late" className="text-xs">
{t("admin.reminderLateMinutes")}
</Label>
<p className="text-muted-foreground text-xs">
{t("admin.reminderLateMinutesDescription")}
</p>
<Input
id="admin-reminder-late"
type="number"
min={15}
max={480}
value={reminderLateDraft ?? settings?.reminderLateMinutes ?? 120}
onChange={(e) => setReminderLateDraft(Number(e.target.value))}
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
disabled={updateSettings.isPending}
className="w-32"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="admin-reminder-missed" className="text-xs">
{t("admin.reminderMissedMinutes")}
</Label>
<p className="text-muted-foreground text-xs">
{t("admin.reminderMissedMinutesDescription")}
</p>
<Input
id="admin-reminder-missed"
type="number"
min={30}
max={720}
value={
reminderMissedDraft ?? settings?.reminderMissedMinutes ?? 240
}
onChange={(e) => setReminderMissedDraft(Number(e.target.value))}
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
disabled={updateSettings.isPending}
className="w-32"
/>
</div>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => testNotification.mutate()}
disabled={testNotification.isPending}
>
{testNotification.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
)}
<Bell className="mr-1.5 h-3.5 w-3.5" />
{t("admin.notificationTestSend")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => reminderCheck.mutate()}
disabled={reminderCheck.isPending}
>
{reminderCheck.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
)}
<Activity className="mr-1.5 h-3.5 w-3.5" />
{t("admin.reminderCheckRun")}
</Button>
<Button
size="sm"
onClick={() => {
updateSettings.mutate(
{
...(reminderLateDraft != null && {
reminderLateMinutes: reminderLateDraft,
}),
...(reminderMissedDraft != null && {
reminderMissedMinutes: reminderMissedDraft,
}),
},
{
onSuccess: () => {
setReminderLateDraft(null);
setReminderMissedDraft(null);
},
},
);
}}
disabled={
updateSettings.isPending ||
(reminderLateDraft == null && reminderMissedDraft == null)
}
>
{updateSettings.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
)}
{t("common.save")}
</Button>
</div>
{testNotification.data?.results &&
testNotification.data.results.length > 0 && (
<div className="mt-4 space-y-1">
{testNotification.data.results.map((r, i) => (
<div key={i} className="flex items-center gap-1.5 text-xs">
{r.success ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-green-400" />
) : (
<XCircle className="h-3.5 w-3.5 shrink-0 text-red-400" />
)}
<span className="font-medium">{r.channel}</span>
{r.error && (
<span className="text-muted-foreground">— {r.error}</span>
)}
</div>
))}
</div>
)}
{reminderCheck.data?.medications &&
reminderCheck.data.medications.length > 0 && (
<div className="mt-4 space-y-2">
<h4 className="text-sm font-medium">
{t("admin.reminderCheckResults")}
</h4>
<div className="space-y-2">
{reminderCheck.data.medications.map((med, i) => (
<div key={i} className="bg-muted/50 space-y-1.5 rounded-lg p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{med.name} ({med.dose})
</span>
<Badge
variant={
med.notificationsEnabled ? "default" : "secondary"
}
>
{med.notificationsEnabled
? t("admin.reminderCheckNotifOn")
: t("admin.reminderCheckNotifOff")}
</Badge>
</div>
<p className="text-muted-foreground text-xs">
{med.user} — {med.dayOfWeek} {med.localTime} —{" "}
{t("admin.reminderCheckEventsToday")}: {med.eventsToday}
</p>
{med.schedules.map((sched, j) => {
const statusColor =
sched.status === "open"
? "text-green-400"
: sched.status === "threshold"
? "text-yellow-400"
: sched.status === "missed"
? "text-red-400"
: sched.status === "skipped"
? "text-muted-foreground"
: "";
return (
<div key={j} className="flex items-start gap-1.5 text-xs">
<span className="text-muted-foreground shrink-0">
{sched.window}
</span>
<span className="text-muted-foreground shrink-0">
[{sched.days}]
</span>
<span className={statusColor}>{sched.label}</span>
{sched.notificationSent && (
<span className="flex shrink-0 items-center gap-0.5 text-green-400">
<Bell className="h-3 w-3" />
{t("admin.reminderCheckNotifSent")}
</span>
)}
</div>
);
})}
</div>
))}
</div>
</div>
)}
</div>
);
}
-68
View File
@@ -1,68 +0,0 @@
"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>
);
}
@@ -1,155 +0,0 @@
"use client";
import {
Activity,
AlertTriangle,
Bell,
BellRing,
Bug,
Clock,
Cog,
Database,
Globe,
Key,
Loader2,
Server,
Users,
} from "lucide-react";
import { formatDateTime } from "@/lib/format";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { StatusItem, useSystemStatus } from "./_shared";
export function SystemStatusSection({ id }: { id: string }) {
const { t } = useTranslations();
const fmt = useFormatters();
const { data: status } = useSystemStatus();
return (
<div
id={id}
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
>
<div className="flex items-center gap-2">
<Server className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("admin.systemStatus")}</h2>
</div>
{status ? (
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatusItem
icon={Database}
label={t("admin.database")}
value={
status.database === "connected"
? t("admin.databaseConnected")
: t("admin.databaseError")
}
className={
status.database === "connected"
? "text-dracula-green"
: "text-destructive"
}
/>
<StatusItem
icon={Clock}
label={t("admin.startedAt")}
value={formatDateTime(status.startTime)}
/>
<StatusItem
icon={Users}
label={t("admin.users")}
value={String(status.counts.users)}
/>
<StatusItem
icon={Activity}
label={t("admin.measurementsCount")}
value={fmt.integer(status.counts.measurements)}
/>
<StatusItem
icon={Key}
label={t("admin.activeTokens")}
value={String(status.counts.activeTokens)}
/>
<StatusItem
icon={Globe}
label={t("admin.activeSessions")}
value={String(status.counts.activeSessions)}
/>
<StatusItem
icon={Cog}
label={t("admin.workerStatus")}
value={
status.worker.running
? t("admin.workerRunning")
: t("admin.workerStopped")
}
className={
status.worker.running ? "text-dracula-green" : "text-destructive"
}
/>
{status.worker.lastReminderCheck && (
<StatusItem
icon={Bell}
label={t("admin.lastReminderCheck")}
value={formatDateTime(status.worker.lastReminderCheck)}
/>
)}
{status.integrations.umami && (
<StatusItem
icon={Activity}
label="Umami"
value={
status.integrations.umami.enabled
? t("common.active")
: t("common.disabled")
}
className={
status.integrations.umami.enabled
? "text-dracula-green"
: "text-destructive"
}
/>
)}
{status.integrations.glitchtip && (
<StatusItem
icon={AlertTriangle}
label="GlitchTip"
value={
status.integrations.glitchtip.enabled
? t("common.active")
: t("common.disabled")
}
className={
status.integrations.glitchtip.enabled
? "text-dracula-green"
: "text-destructive"
}
/>
)}
{status.integrations.webPush && (
<StatusItem
icon={BellRing}
label={t("admin.integrationWebPush")}
value={t("admin.configured")}
className="text-dracula-green"
/>
)}
{status.integrations.bugReport && (
<StatusItem
icon={Bug}
label={t("admin.integrationBugReport")}
value={t("admin.configured")}
className="text-dracula-green"
/>
)}
</div>
) : (
<div className="mt-4 flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<span className="text-muted-foreground text-sm">
{t("admin.loadingStatus")}
</span>
</div>
)}
</div>
);
}
-173
View File
@@ -1,173 +0,0 @@
"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>
);
}
@@ -1,341 +0,0 @@
"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>
);
}
@@ -1,148 +0,0 @@
"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>
);
}