From abcfcd9cb6bcb039ada17b580b2abd3f62932a4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 8 May 2026 15:57:28 +0200 Subject: [PATCH] feat(admin): extract per-section components from admin page monolith MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin page was a single 2,702-line client component with nineteen inline functions covering everything from system status to feedback inbox. The status-card grid foundation in #133 already moved the at-a-glance dashboard out; this commit moves the inner panels. Split into one file per logical section in src/components/admin/: _shared.tsx 276 LOC shared types, hooks, primitives (PasswordInput, StatusItem, SettingsToggle, useAdminSettings, useUpdateSettings, useSystemStatus, getApiErrorMessage) system-status-section.tsx 155 general-settings-section.tsx 54 services-section.tsx 68 umami-section.tsx 173 glitchtip-section.tsx 172 web-push-vapid-section.tsx 148 bug-report-section.tsx 109 reminders-section.tsx 318 user-management-section.tsx 341 api-token-overview-section.tsx 144 login-overview-section.tsx 181 danger-zone-section.tsx 118 feedback-inbox-section.tsx 511 (inbox + category badge + detail dialog) src/app/admin/page.tsx is now a 77-line shell that imports the 14 sections and the existing status-card-grid.tsx. No behavior change — every section keeps the same DOM, the same query keys, the same i18n keys, the same id="…" anchors so deep-links from the sidebar still scroll into place. UserManagementSection no longer takes a queryClient prop; it grabs its own via useQueryClient() like every other section does. Co-Authored-By: Marc-André Bombeck --- src/app/admin/page.tsx | 2653 +---------------- src/components/admin/_shared.tsx | 276 ++ .../admin/api-token-overview-section.tsx | 144 + src/components/admin/bug-report-section.tsx | 109 + src/components/admin/danger-zone-section.tsx | 118 + .../admin/feedback-inbox-section.tsx | 511 ++++ .../admin/general-settings-section.tsx | 54 + src/components/admin/glitchtip-section.tsx | 172 ++ .../admin/login-overview-section.tsx | 181 ++ src/components/admin/reminders-section.tsx | 318 ++ src/components/admin/services-section.tsx | 68 + .../admin/system-status-section.tsx | 155 + src/components/admin/umami-section.tsx | 173 ++ .../admin/user-management-section.tsx | 341 +++ .../admin/web-push-vapid-section.tsx | 148 + 15 files changed, 2782 insertions(+), 2639 deletions(-) create mode 100644 src/components/admin/_shared.tsx create mode 100644 src/components/admin/api-token-overview-section.tsx create mode 100644 src/components/admin/bug-report-section.tsx create mode 100644 src/components/admin/danger-zone-section.tsx create mode 100644 src/components/admin/feedback-inbox-section.tsx create mode 100644 src/components/admin/general-settings-section.tsx create mode 100644 src/components/admin/glitchtip-section.tsx create mode 100644 src/components/admin/login-overview-section.tsx create mode 100644 src/components/admin/reminders-section.tsx create mode 100644 src/components/admin/services-section.tsx create mode 100644 src/components/admin/system-status-section.tsx create mode 100644 src/components/admin/umami-section.tsx create mode 100644 src/components/admin/user-management-section.tsx create mode 100644 src/components/admin/web-push-vapid-section.tsx diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 6f8a8a9..688dcf1 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,155 +1,24 @@ "use client"; -import { useState } from "react"; import { useAuth } from "@/hooks/use-auth"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Badge } from "@/components/ui/badge"; -import { Switch } from "@/components/ui/switch"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from "@/components/ui/dialog"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; -import { - Shield, - Users, - Settings, - Loader2, - KeyRound, - Pencil, - AlertTriangle, - Trash2, - Activity, - Database, - Server, - ScrollText, - ChevronDown, - XCircle, - CheckCircle2, - Eye, - EyeOff, - Clock, - Globe, - MessageCircle, - Bell, - BellRing, - Key, - Bug, - Cog, - MessageSquare, - Inbox, - ExternalLink, - GitPullRequest, -} from "lucide-react"; -import { PasswordStrength } from "@/components/ui/password-strength"; -import { formatDate, formatDateTime } from "@/lib/format"; -import { useTranslations, useFormatters } from "@/lib/i18n/context"; -import { toast } from "sonner"; +import { useTranslations } from "@/lib/i18n/context"; +import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-section"; +import { BugReportSection } from "@/components/admin/bug-report-section"; +import { DangerZoneSection } from "@/components/admin/danger-zone-section"; +import { FeedbackInboxSection } from "@/components/admin/feedback-inbox-section"; +import { GeneralSettingsSection } from "@/components/admin/general-settings-section"; +import { GlitchtipSection } from "@/components/admin/glitchtip-section"; +import { LoginOverviewSection } from "@/components/admin/login-overview-section"; +import { RemindersSection } from "@/components/admin/reminders-section"; +import { ServicesSection } from "@/components/admin/services-section"; import { StatusCardGrid } from "@/components/admin/status-card-grid"; - -function PasswordInput(props: React.ComponentProps) { - const [visible, setVisible] = useState(false); - return ( -
- - -
- ); -} - -interface AdminUser { - id: string; - username: string; - email: string | null; - role: string; - createdAt: string; - passkeyCount: number; -} - -interface WorkerStatus { - running: boolean; - startedAt: string | null; - lastHeartbeat: string | null; - lastReminderCheck: string | null; - lastWithingsSync: string | null; - lastInsightsRun: string | null; - jobsProcessed: number; - errors: number; -} - -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; - }; -} - -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; -} +import { SystemStatusSection } from "@/components/admin/system-status-section"; +import { UmamiSection } from "@/components/admin/umami-section"; +import { UserManagementSection } from "@/components/admin/user-management-section"; +import { WebPushVapidSection } from "@/components/admin/web-push-vapid-section"; export default function AdminPage() { const { user } = useAuth(); - const queryClient = useQueryClient(); const { t } = useTranslations(); if (!user || user.role !== "ADMIN") return null; @@ -197,7 +66,6 @@ export default function AdminPage() { @@ -207,2496 +75,3 @@ export default function AdminPage() { ); } - -/* ─────────────────────── Shared helpers ─────────────────────── */ - -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; - }, - }); -} - -function useUpdateSettings() { - const queryClient = useQueryClient(); - const { t } = useTranslations(); - return useMutation({ - mutationFn: async (data: Record) => { - 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"), - ); - }, - }); -} - -async function getApiErrorMessage(response: Response): Promise { - 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; -} - -/* ─────────────────────── System Status ─────────────────────── */ - -function SystemStatusSection({ id }: { id: string }) { - const { t } = useTranslations(); - const fmt = useFormatters(); - - const { data: status } = 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; - }, - }); - - return ( -
-
- -

{t("admin.systemStatus")}

-
- {status ? ( -
- - - - - - - - {status.worker.lastReminderCheck && ( - - )} - {status.integrations.umami && ( - - )} - {status.integrations.glitchtip && ( - - )} - {status.integrations.webPush && ( - - )} - {status.integrations.bugReport && ( - - )} -
- ) : ( -
- - - {t("admin.loadingStatus")} - -
- )} -
- ); -} - -function StatusItem({ - icon: Icon, - label, - value, - className, -}: { - icon: React.ComponentType<{ className?: string }>; - label: string; - value: string; - className?: string; -}) { - return ( -
-
- - {label} -
-

{value}

-
- ); -} - -/* ─────────────────────── General Settings ─────────────────────── */ - -function GeneralSettingsSection({ id }: { id: string }) { - const { t } = useTranslations(); - const { data: settings } = useAdminSettings(); - const updateSettings = useUpdateSettings(); - - return ( -
-
- -

{t("admin.appSettings")}

-
-
- - updateSettings.mutate({ registrationEnabled: checked }) - } - disabled={updateSettings.isPending} - /> - -
-
-

{t("admin.defaultLanguage")}

-

- {t("admin.defaultLanguageDescription")} -

-
- -
-
-
- ); -} - -/* ─────────────────────── Services ─────────────────────── */ - -function ServicesSection({ id }: { id: string }) { - const { t } = useTranslations(); - const { data: settings } = useAdminSettings(); - const updateSettings = useUpdateSettings(); - - return ( -
-
- -

{t("admin.servicesGlobal")}

-
-

- {t("admin.servicesGlobalDescription")} -

-
- - updateSettings.mutate({ telegramGlobal: checked }) - } - disabled={updateSettings.isPending} - /> - - updateSettings.mutate({ ntfyGlobal: checked }) - } - disabled={updateSettings.isPending} - /> - - updateSettings.mutate({ webPushGlobal: checked }) - } - disabled={updateSettings.isPending} - /> - - updateSettings.mutate({ apiGlobal: checked }) - } - disabled={updateSettings.isPending} - /> -
-
- ); -} - -/* ─────────────────────── Umami ─────────────────────── */ - -function UmamiSection({ id }: { id: string }) { - const { t } = useTranslations(); - const { data: settings } = useAdminSettings(); - const updateSettings = useUpdateSettings(); - const [umamiScriptUrlDraft, setUmamiScriptUrlDraft] = useState( - null, - ); - const [umamiWebsiteIdDraft, setUmamiWebsiteIdDraft] = useState( - 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 ( -
-
-
- -

{t("admin.umamiTitle")}

-
-
- {configured && ( - - {t("admin.configured")} - - )} -
-
-

- {t("admin.umamiDescription")} -

- -
- - updateSettings.mutate({ umamiEnabled: checked }) - } - disabled={updateSettings.isPending} - /> -
-
- - setUmamiScriptUrlDraft(event.target.value)} - placeholder={t("admin.umamiScriptUrlPlaceholder")} - autoComplete="new-password" - spellCheck={false} - data-lpignore="true" - data-1p-ignore="true" - disabled={updateSettings.isPending} - /> -
-
- - setUmamiWebsiteIdDraft(event.target.value)} - placeholder={t("admin.umamiWebsiteIdPlaceholder")} - autoComplete="new-password" - spellCheck={false} - data-lpignore="true" - data-1p-ignore="true" - disabled={updateSettings.isPending} - /> -
-
-
- -
- - -
-
- ); -} - -/* ─────────────────────── GlitchTip ─────────────────────── */ - -function GlitchtipSection({ id }: { id: string }) { - const { t } = useTranslations(); - const { data: settings } = useAdminSettings(); - const updateSettings = useUpdateSettings(); - const [glitchtipDsnDraft, setGlitchtipDsnDraft] = useState( - 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 ( -
-
-
- -

{t("admin.glitchtipTitle")}

-
-
- {configured && ( - - {t("admin.configured")} - - )} -
-
-

- {t("admin.glitchtipDescription")} -

- -
- - updateSettings.mutate({ glitchtipEnabled: checked }) - } - disabled={updateSettings.isPending} - /> -
-
- - setGlitchtipDsnDraft(event.target.value)} - placeholder={t("admin.glitchtipDsnPlaceholder")} - autoComplete="new-password" - spellCheck={false} - data-lpignore="true" - data-1p-ignore="true" - disabled={updateSettings.isPending} - /> -
-
- - - setGlitchtipEnvironmentDraft(event.target.value) - } - placeholder={t("admin.glitchtipEnvironmentPlaceholder")} - autoComplete="new-password" - spellCheck={false} - data-lpignore="true" - data-1p-ignore="true" - disabled={updateSettings.isPending} - /> -
-
-
- -
- - -
-
- ); -} - -/* ─────────────────────── Web Push VAPID ─────────────────────── */ - -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 = { - webPushVapidPublicKey: webPushVapidPublicKeyValue, - webPushVapidSubject: webPushVapidSubjectValue, - }; - if (webPushVapidPrivateKeyDraft.trim().length > 0) { - payload.webPushVapidPrivateKey = webPushVapidPrivateKeyDraft.trim(); - } - - updateSettings.mutate(payload, { - onSuccess: () => { - setWebPushVapidPublicKeyDraft(null); - setWebPushVapidPrivateKeyDraft(""); - setWebPushVapidSubjectDraft(null); - }, - }); - } - - return ( -
-
-
- -

- {t("admin.webPushVapidTitle")} -

-
-
- {configured && ( - - {t("admin.configured")} - - )} -
-
-

- {t("admin.webPushVapidDescription")} -

- -
-
- - - setWebPushVapidPublicKeyDraft(event.target.value) - } - placeholder={t("admin.webPushVapidPublicKeyPlaceholder")} - autoComplete="new-password" - spellCheck={false} - data-lpignore="true" - data-1p-ignore="true" - disabled={updateSettings.isPending} - /> -
-
- - - setWebPushVapidPrivateKeyDraft(event.target.value) - } - placeholder={t("admin.webPushVapidPrivateKeyPlaceholder")} - autoComplete="new-password" - spellCheck={false} - data-lpignore="true" - data-1p-ignore="true" - disabled={updateSettings.isPending} - /> -
-
- - - setWebPushVapidSubjectDraft(event.target.value) - } - placeholder={t("admin.webPushVapidSubjectPlaceholder")} - autoComplete="new-password" - spellCheck={false} - data-lpignore="true" - data-1p-ignore="true" - disabled={updateSettings.isPending} - /> -
-
- -
- -
-
- ); -} - -/* ─────────────────────── Bug Report ─────────────────────── */ - -function BugReportSection({ id }: { id: string }) { - const { t } = useTranslations(); - const { data: settings } = useAdminSettings(); - const updateSettings = useUpdateSettings(); - const [bugReportRepoDraft, setBugReportRepoDraft] = useState( - null, - ); - const [bugReportTokenDraft, setBugReportTokenDraft] = useState(""); - - const bugReportRepoValue = - bugReportRepoDraft ?? settings?.bugReportRepo ?? ""; - const configured = settings?.bugReportConfigured ?? false; - - function saveBugReportSettings() { - const payload: Record = { - bugReportRepo: bugReportRepoValue, - }; - if (bugReportTokenDraft.trim().length > 0) { - payload.bugReportToken = bugReportTokenDraft.trim(); - } - - updateSettings.mutate(payload, { - onSuccess: () => { - setBugReportRepoDraft(null); - setBugReportTokenDraft(""); - }, - }); - } - - return ( -
-
-
- -

- {t("admin.bugReportGithub")} -

-
-
- {configured && ( - - {t("admin.configured")} - - )} -
-
-

- {t("admin.bugReportGithubDescription")} -

- -
-
- - setBugReportRepoDraft(event.target.value)} - placeholder={t("admin.bugReportRepoPlaceholder")} - autoComplete="off" - data-lpignore="true" - data-1p-ignore="true" - disabled={updateSettings.isPending} - /> -
-
- - setBugReportTokenDraft(event.target.value)} - placeholder={t("admin.bugReportTokenPlaceholder")} - disabled={updateSettings.isPending} - /> -
-
- -
- -
-
- ); -} - -/* ─────────────────────── Reminders ─────────────────────── */ - -function RemindersSection({ id }: { id: string }) { - const { t } = useTranslations(); - const { data: settings } = useAdminSettings(); - const updateSettings = useUpdateSettings(); - const [reminderLateDraft, setReminderLateDraft] = useState( - null, - ); - const [reminderMissedDraft, setReminderMissedDraft] = useState( - 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 ( -
-
- -

- {t("admin.medicationReminders")} -

-
-

- {t("admin.medicationRemindersDescription")} -

- -
-
-
- -

- {t("admin.reminderLateMinutesDescription")} -

- setReminderLateDraft(Number(e.target.value))} - autoComplete="off" - data-lpignore="true" - data-1p-ignore="true" - disabled={updateSettings.isPending} - className="w-32" - /> -
-
- -

- {t("admin.reminderMissedMinutesDescription")} -

- setReminderMissedDraft(Number(e.target.value))} - autoComplete="off" - data-lpignore="true" - data-1p-ignore="true" - disabled={updateSettings.isPending} - className="w-32" - /> -
-
-
- -
- - - -
- - {testNotification.data?.results && - testNotification.data.results.length > 0 && ( -
- {testNotification.data.results.map((r, i) => ( -
- {r.success ? ( - - ) : ( - - )} - {r.channel} - {r.error && ( - — {r.error} - )} -
- ))} -
- )} - - {reminderCheck.data?.medications && - reminderCheck.data.medications.length > 0 && ( -
-

- {t("admin.reminderCheckResults")} -

-
- {reminderCheck.data.medications.map((med, i) => ( -
-
- - {med.name} ({med.dose}) - - - {med.notificationsEnabled - ? t("admin.reminderCheckNotifOn") - : t("admin.reminderCheckNotifOff")} - -
-

- {med.user} — {med.dayOfWeek} {med.localTime} —{" "} - {t("admin.reminderCheckEventsToday")}: {med.eventsToday} -

- {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 ( -
- - {sched.window} - - - [{sched.days}] - - {sched.label} - {sched.notificationSent && ( - - - {t("admin.reminderCheckNotifSent")} - - )} -
- ); - })} -
- ))} -
-
- )} -
- ); -} - -/* ─────────────────────── Shared UI Components ─────────────────────── */ - -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 ( -
-
- {Icon && } -
-

{label}

-

{description}

-
-
- -
- ); -} - -/* ─────────────────────── Login Overview ─────────────────────── */ - -interface AdminAuditEntry { - id: string; - action: string; - ipAddress: string | null; - location: string | null; - details: string | null; - createdAt: string; - user: { id: string; username: string } | null; -} - -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 = { - "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 ( -
-
-
- -

{t("admin.loginOverview")}

-
- -
- - {expanded && ( -
-
- - -
- - {isLoading ? ( -
- -
- ) : !entries?.length ? ( -

- {t("admin.noEntries")} -

- ) : ( -
- - - - - - - - - - - - - {entries.map((entry, i) => { - const isFailed = entry.action === "auth.login.failed"; - return ( - - - - - - - - - ); - })} - -
- {t("admin.status")} - - {t("admin.users")} - - {t("admin.action")} - - {t("admin.ip")} - - {t("admin.location")} - - {t("admin.timestamp")} -
- {isFailed ? ( - - ) : ( - - )} - - {entry.user?.username ?? t("common.unknown")} - - {AUTH_ACTION_LABELS[entry.action] ?? entry.action} - - {entry.ipAddress ?? "—"} - - {entry.location ?? "—"} - - {formatDateTime(entry.createdAt)} -
- {data && data.meta.total > entries.length && ( -

- {t("admin.showingEntries", { - count: entries.length, - total: data.meta.total, - })} -

- )} -
- )} -
- )} -
- ); -} - -/* ─────────────────────── API Token Overview ─────────────────────── */ - -interface ApiTokenInfo { - id: string; - name: string; - permissions: string[]; - lastUsedAt: string | null; - expiresAt: string | null; - createdAt: string; - revoked: boolean; - user: { id: string; username: string }; -} - -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 ( -
-
-
- -

{t("admin.apiTokens")}

-
- -
- - {expanded && ( -
- {isLoading ? ( -
- -
- ) : !tokens?.length ? ( -

- {t("admin.noTokens")} -

- ) : ( -
- - - - - - - - - - - - - {tokens.map((token, i) => { - const isExpired = - token.expiresAt && new Date(token.expiresAt) < new Date(); - return ( - - - - - - - - - ); - })} - -
- {t("admin.tokenUser")} - - {t("admin.tokenName")} - - {t("admin.tokenPermissions")} - - {t("admin.tokenStatus")} - - {t("admin.tokenLastUsed")} - - {t("admin.tokenCreated")} -
- {token.user.username} - {token.name} -
- {token.permissions.map((p) => ( - - {p} - - ))} -
-
- {token.revoked ? ( - - {t("settings.tokenRevoked")} - - ) : isExpired ? ( - - {t("settings.tokenExpired")} - - ) : ( - - {t("common.active")} - - )} - - {token.lastUsedAt - ? formatDateTime(token.lastUsedAt) - : t("admin.tokenNeverUsed")} - - {formatDate(token.createdAt)} -
-
- )} -
- )} -
- ); -} - -/* ─────────────────────── Danger Zone ─────────────────────── */ - -function DangerZoneSection({ id }: { id: string }) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const [wipeMsg, setWipeMsg] = useState(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 ( -
-
- -

- {t("admin.dangerZone")} -

-
-
-

{t("admin.deleteAllData")}

-

- {t("admin.deleteAllDescription")} -

-
- - - - - - - - {t("admin.deleteAllConfirm")} - - - {t("admin.deleteAllConfirmDescription")} - - - - {t("common.cancel")} - wipeAllData.mutate()} - > - {t("admin.finalDelete")} - - - - -
- {wipeMsg && ( -

- {wipeMsg} -

- )} -
-
- ); -} - -/* ─────────────────────── User Management ─────────────────────── */ - -function UserManagementSection({ - id, - queryClient, - currentUserId, -}: { - id: string; - queryClient: ReturnType; - currentUserId: string; -}) { - const { t } = useTranslations(); - const [editingUser, setEditingUser] = useState(null); - const [editUsername, setEditUsername] = useState(""); - const [editEmail, setEditEmail] = useState(""); - const [resetUser, setResetUser] = useState(null); - const [resetPassword, setResetPassword] = useState(""); - const [resetMsg, setResetMsg] = useState(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; - }) => { - 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 ( -
-
- -

{t("admin.userManagement")}

- {users && ( - - {users.length} - - )} -
- - {users ? ( -
- - - - - - - - - - - - - {users.map((u, i) => ( - - - - - - - - - ))} - -
- {t("admin.users")} - - {t("admin.userEmail")} - - {t("admin.userRole")} - - {t("admin.userPasskeys")} - - {t("admin.userCreated")} - - {t("admin.userActions")} -
{u.username} - {u.email || "—"} - - - {u.role} - - {u.passkeyCount} - {formatDate(u.createdAt)} - -
- - - -
-
-
- ) : ( -
- - - {t("admin.loadingUsers")} - -
- )} - - {/* Edit Dialog */} - {editingUser && ( -
-

- {t("admin.editUserTitle", { name: editingUser.username })} -

-
-
- - setEditUsername(e.target.value)} - autoComplete="off" - data-lpignore="true" - data-1p-ignore="true" - /> -
-
- - setEditEmail(e.target.value)} - placeholder={t("common.optional")} - autoComplete="off" - data-lpignore="true" - data-1p-ignore="true" - /> -
-
- - - {updateUser.isError && ( - - {(updateUser.error as Error).message} - - )} -
-
-
- )} - - {/* Password Reset Dialog */} - {resetUser && ( -
-

- {t("admin.resetPasswordTitle", { name: resetUser.username })} -

-
-
- - setResetPassword(e.target.value)} - placeholder={t("admin.newPasswordPlaceholder")} - /> - -
-
- - -
- {resetMsg && ( -

- {resetMsg} -

- )} -
-
- )} -
- ); -} - -/* ─────────────────────── Feedback Inbox ─────────────────────── */ - -type FeedbackStatusType = "OPEN" | "ACKNOWLEDGED" | "RESOLVED" | "ARCHIVED"; -type FeedbackCategoryType = "BUG" | "FEATURE_REQUEST" | "QUESTION" | "OTHER"; - -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 | null; - screenshotBase64: string | null; - createdAt: string; - updatedAt: string; - user: { username: string } | null; -} - -interface FeedbackListResponse { - items: FeedbackItem[]; - meta: { - total: number; - limit: number; - offset: number; - countsByStatus: Partial>; - }; -} - -const STATUS_TABS: FeedbackStatusType[] = [ - "OPEN", - "ACKNOWLEDGED", - "RESOLVED", - "ARCHIVED", -]; - -function FeedbackInboxSection({ id }: { id: string }) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const { data: status } = 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; - }, - }); - const githubConfigured = Boolean(status?.integrations.bugReport?.configured); - - const [activeStatus, setActiveStatus] = useState("OPEN"); - const [selected, setSelected] = useState(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 ( -
-
- -

{t("admin.feedback.title")}

-
-

- {t("admin.feedback.description")} -

- - setActiveStatus(v as FeedbackStatusType)} - className="mt-4" - > - - {STATUS_TABS.map((s) => ( - - - {t( - `admin.feedback.tab${s.charAt(0) + s.slice(1).toLowerCase()}`, - )} - - - {counts[s] ?? 0} - - - ))} - - - {STATUS_TABS.map((s) => ( - - {isLoading ? ( -
- - - {t("admin.feedback.loading")} - -
- ) : !data?.items.length ? ( -

- {t("admin.feedback.noEntries")} -

- ) : ( -
- - - - - - - - - - - - {data.items.map((item, i) => ( - setSelected(item)} - > - - - - - - - ))} - -
- {t("admin.feedback.createdAt")} - - {t("admin.feedback.category")} - - {t("admin.feedback.subject")} - - {t("admin.feedback.user")} - - {t("admin.feedback.actions")} -
- {formatDateTime(item.createdAt)} - - - - {item.subject} - - {item.user?.username ?? t("admin.feedback.anonymous")} - - -
-
- )} -
- ))} -
- - {selected && ( - !open && setSelected(null)} - githubConfigured={githubConfigured} - onMutated={refresh} - /> - )} -
- ); -} - -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 ( - {cfg.label} - ); -} - -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 ( - - - - - - {item.subject} - - - - - - {t("admin.feedback.submittedBy")}:{" "} - {item.user?.username ?? t("admin.feedback.anonymous")} - - · - {formatDateTime(item.createdAt)} - {issueUrl && ( - - - {t("admin.feedback.viewIssue")} - - - )} - - - - -
-
- {item.description} -
- - {(url || locale || userAgent || appVersion) && ( -
-

- {t("admin.feedback.metadataHeading")} -

-
- {url && ( - <> -
- {t("admin.feedback.metaUrl")} -
-
{url}
- - )} - {locale && ( - <> -
- {t("admin.feedback.metaLocale")} -
-
{locale}
- - )} - {appVersion && ( - <> -
- {t("admin.feedback.metaAppVersion")} -
-
{appVersion}
- - )} - {userAgent && ( - <> -
- {t("admin.feedback.metaUserAgent")} -
-
{userAgent}
- - )} -
-
- )} - - {item.screenshotBase64 && ( -
-

- {t("admin.feedback.screenshotHeading")} -

- {/* eslint-disable-next-line @next/next/no-img-element */} - Screenshot -
- )} - -
- -