merge: remove the bug-report integration for v1.23

# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/pl.json
#	src/app/admin/[section]/renderer.tsx
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:47:24 +02:00
commit b271a39fe4
68 files changed
+115 -3344

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@ React components. The UI primitives are shadcn/ui (new-york style, zinc base) ov
- **`ui/`** — shadcn/ui primitives (button, dialog, sheet, table, …). Generated by the shadcn CLI; keep edits minimal and on-style.
- **`layout/`** — the app shell: sidebar, topbar, bottom nav.
- **`providers.tsx`, `app-settings-provider.tsx`, `version-poller.tsx`** — top-level context providers and the self-healing stale-shell poller.
- **`providers.tsx`, `version-poller.tsx`** — top-level context providers and the self-healing stale-shell poller.
- **`i18n/`** — translation context + locale switcher.
## Feature groups
+11 -21
View File
@@ -1,24 +1,14 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
// `<ErrorDetails>` is a client component used by every Next.js
// `error.tsx` boundary. It surfaces a "Report bug" link whose
// visibility must mirror the admin's `bugReportEnabled` toggle —
// otherwise the user lands on a `/bugreport` page that immediately
// renders a "Bug reports are disabled" notice.
const mockSettingsRef = { value: { bugReportEnabled: true } };
vi.mock("@/components/app-settings-provider", () => ({
useAppSettings: () => mockSettingsRef.value,
}));
// `<ErrorDetails>` is a client component used by every Next.js `error.tsx`
// boundary. It surfaces the error message plus a copy-to-clipboard diagnostic
// payload — and, since v1.23, no longer links out to a bug-report surface.
import { I18nProvider } from "@/lib/i18n/context";
import { ErrorDetails } from "../error-details";
function render({
bugReportEnabled = true,
}: { bugReportEnabled?: boolean } = {}) {
mockSettingsRef.value = { bugReportEnabled };
function render() {
const error = Object.assign(new Error("test failure"), {
digest: "abc123",
});
@@ -29,14 +19,14 @@ function render({
);
}
describe("<ErrorDetails> bug-report toggle", () => {
it("shows the Report bug button when the admin flag is enabled", () => {
const html = render({ bugReportEnabled: true });
expect(html).toContain('href="/bugreport"');
describe("<ErrorDetails>", () => {
it("renders the error message", () => {
const html = render();
expect(html).toContain("test failure");
});
it("hides the Report bug button when the admin flag is disabled", () => {
const html = render({ bugReportEnabled: false });
it("does not link out to a bug-report surface", () => {
const html = render();
expect(html).not.toContain('href="/bugreport"');
});
});
@@ -46,7 +46,6 @@ import { AiQualitySection } from "../ai-quality-section";
import { ApiTokenOverviewSection } from "../api-token-overview-section";
import { BackupsSection } from "../backups-section";
import { DangerZoneSection } from "../danger-zone-section";
import { FeedbackInboxSection } from "../feedback-inbox-section";
import { GeneralSettingsSection } from "../general-settings-section";
import { IntegrationsGroupSection } from "../integrations-group-section";
import { LoginOverviewSection } from "../login-overview-section";
@@ -89,22 +88,13 @@ describe("admin sections — SSR smoke", () => {
expect(html).toContain("Telegram");
});
it("<IntegrationsGroupSection> renders all four sub-sections", () => {
it("<IntegrationsGroupSection> renders all sub-sections", () => {
const html = render(<IntegrationsGroupSection />);
expect(html).toContain("Umami");
expect(html).toContain("GlitchTip");
expect(html).toContain("Web Push");
});
it("<FeedbackInboxSection> renders", () => {
const html = render(<FeedbackInboxSection />);
// F-08 dedupe: card no longer carries a duplicate "Feedback"
// header. Anchor on the empty-state copy + status tabs instead.
expect(html).toContain("All caught up");
expect(html).toContain("Open");
expect(html).toContain("Resolved");
});
it("<RemindersSection> renders", () => {
const html = render(<RemindersSection />);
expect(html).toContain("Medication Reminders");
+3 -52
View File
@@ -60,7 +60,6 @@ export interface SystemStatus {
umami: { configured: boolean; enabled: boolean } | null;
glitchtip: { configured: boolean; enabled: boolean } | null;
webPush: { configured: boolean } | null;
bugReport: { configured: boolean } | null;
};
}
@@ -80,9 +79,6 @@ export interface AdminSettings {
glitchtipEnabled: boolean;
glitchtipDsn: string | null;
glitchtipEnvironment: string | null;
bugReportRepo: string | null;
bugReportConfigured: boolean;
bugReportEnabled: boolean;
reminderLateMinutes: number;
reminderMissedMinutes: number;
// v1.4.25 W7 — null means "fall back to Europe/Berlin in the resolver".
@@ -116,54 +112,9 @@ export interface ApiTokenInfo {
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",
];
/** Shared green "configured" pill — the GlitchTip, Umami, bug-report,
* and Web-Push VAPID cards all surface the same state; one component
* keeps the tint and copy in lockstep. */
/** Shared green "configured" pill — the GlitchTip, Umami, and Web-Push
* VAPID cards all surface the same state; one component keeps the tint
* and copy in lockstep. */
export function ConfiguredBadge() {
const { t } = useTranslations();
return (
-6
View File
@@ -23,7 +23,6 @@ import {
FileText,
Info,
KeyRound,
Inbox,
Plug,
Radio,
ScrollText,
@@ -90,11 +89,6 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
titleKey: "admin.section.module-availability.title",
icon: Blocks,
},
{
slug: "feedback",
titleKey: "admin.section.feedback.title",
icon: Inbox,
},
{
slug: "reminders",
titleKey: "admin.section.reminders.title",
-142
View File
@@ -1,142 +0,0 @@
"use client";
import { useState } from "react";
import { Bug, Loader2 } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import {
ConfiguredBadge,
PasswordInput,
useAdminSettings,
useUpdateSettings,
} from "./_shared";
export function BugReportSection() {
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;
const enabled = settings?.bugReportEnabled ?? true;
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("");
},
});
}
function toggleEnabled(next: boolean) {
updateSettings.mutate({ bugReportEnabled: next });
}
return (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Bug}
title={t("admin.bugReportGithub")}
titleAccessory={
// v1.18.10 (W7) — the feedback / bug-report feature is deprecated.
// The badge + note signal the upcoming removal without pulling the
// functionality yet, so existing operators keep the surface working.
<Badge
variant="outline"
data-testid="bug-report-deprecated-badge"
className="border-amber-500/50 text-amber-600 dark:text-amber-400"
>
{t("admin.bugReportDeprecatedBadge")}
</Badge>
}
description={
<>
<p>{t("admin.bugReportGithubDescription")}</p>
<p className="text-amber-600 dark:text-amber-400">
{t("admin.bugReportDeprecatedNote")}
</p>
</>
}
status={configured ? <ConfiguredBadge /> : null}
/>
<div className="bg-muted/40 mt-4 ml-7 flex items-center justify-between gap-3 rounded-lg p-3">
<div>
<p className="text-sm font-medium">
{t("admin.bugReportEnabledLabel")}
</p>
<p className="text-muted-foreground text-xs">
{t("admin.bugReportEnabledDescription")}
</p>
</div>
<Switch
checked={enabled}
onCheckedChange={toggleEnabled}
disabled={updateSettings.isPending}
aria-label={t("admin.bugReportEnabledLabel")}
/>
</div>
<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="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</div>
</div>
);
}
@@ -1,517 +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 { EmptyState } from "@/components/ui/empty-state";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
FEEDBACK_STATUS_TABS,
type FeedbackCategoryType,
type FeedbackItem,
type FeedbackListResponse,
type FeedbackStatusType,
useSystemStatus,
} from "./_shared";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
export function FeedbackInboxSection() {
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: queryKeys.adminFeedback(activeStatus),
queryFn: async () => {
return apiGet<FeedbackListResponse>(
`/api/admin/feedback?status=${activeStatus}&limit=100`,
);
},
});
const counts = data?.meta?.countsByStatus ?? {};
function refresh() {
queryClient.invalidateQueries({ queryKey: queryKeys.adminFeedbackRoot() });
}
return (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
{/* v1.4.19 A8 / F-08 + F-20: this is the only card on
`/admin/feedback`, so the page header already provides the
title + description — the card-level repetition was just
visual noise. The icon stays as a low-key card affordance. */}
<Inbox className="text-muted-foreground h-5 w-5" aria-hidden="true" />
<Tabs
value={activeStatus}
onValueChange={(v) => setActiveStatus(v as FeedbackStatusType)}
className="mt-4"
>
{/* v1.4.27 MB7 / CF-55 — wrap the TabsList in an
overflow-x-auto strip so the 4 status pills + their count
badges scroll horizontally on Pixel 5 / Galaxy Fold
instead of squashing the badge text below the legibility
floor. Hide the scrollbar visually but keep the gesture
working (matches the Insights tab-strip pattern). */}
<div className="-mx-1 [scrollbar-width:none] overflow-x-auto px-1 [&::-webkit-scrollbar]:hidden">
<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>
</div>
{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 motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("admin.feedback.loading")}
</span>
</div>
) : !data?.items?.length ? (
// v1.4.15 phase-C5: per-tab empty state. Compact size so
// it sits naturally under the tab strip without dwarfing
// a typical 1-2-row inbox once entries arrive.
<EmptyState
size="compact"
icon={<Inbox className="size-5" />}
title={t("admin.feedback.emptyTitle")}
description={t("admin.feedback.emptyDescription")}
/>
) : (
<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"),
// Bug = something that's broken → semantic `destructive` token instead
// of raw `dracula-red`. Visually identical in dark mode (both resolve
// to #ff5555); light mode now picks up the AA-contrast `#cb3a2a`.
className: "bg-destructive/15 text-destructive border-destructive/30",
},
FEATURE_REQUEST: {
label: t("admin.feedback.categoryFeature"),
className:
"bg-dracula-purple/15 text-dracula-purple border-dracula-purple/30",
},
QUESTION: {
label: t("admin.feedback.categoryQuestion"),
className: "bg-dracula-cyan/15 text-dracula-cyan border-dracula-cyan/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;
}) => {
await apiPatch(`/api/admin/feedback/${item.id}`, payload);
},
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 () => {
await apiDelete(`/api/admin/feedback/${item.id}`);
},
onSuccess: () => {
onMutated();
onOpenChange(false);
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : t("admin.feedback.updateFailed"),
);
},
});
const publish = useMutation({
mutationFn: async () => {
return apiPost<{ issueUrl: string }>(
`/api/admin/feedback/${item.id}/github`,
);
},
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 (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={
<span className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
{item.subject}
</span>
}
description={
<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>
}
className="sm:max-w-2xl"
footer={
<div className="flex w-full flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={update.isPending || item.status === "ACKNOWLEDGED"}
onClick={() => update.mutate({ status: "ACKNOWLEDGED" })}
>
<CheckCircle2 className="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="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="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()}
variant="destructive"
disabled={archive.isPending}
aria-busy={archive.isPending || undefined}
>
{archive.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{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="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<GitPullRequest className="h-3.5 w-3.5" />
)}
{t("admin.feedback.actionPublishGithub")}
</Button>
)}
{issueUrl && (
<Badge className="border-success/30 bg-success/15 text-success ml-auto">
{t("admin.feedback.publishedToGithub")}
</Badge>
)}
</div>
}
>
<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>
{/* v1.4.33 IW9 — h4 -> h3 so the sheet outline stays
sequential beneath the ResponsiveSheet's h2 title. */}
<h3 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
{t("admin.feedback.metadataHeading")}
</h3>
<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>
<h3 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
{t("admin.feedback.screenshotHeading")}
</h3>
{/* 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")}
/>
<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="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("admin.feedback.saveNote")}
</Button>
</div>
</div>
</div>
</ResponsiveSheet>
);
}
@@ -3,13 +3,11 @@
/**
* `<IntegrationsGroupSection>` — composite admin route at `/admin/integrations`.
*
* Wraps the four standalone integration sub-sections (Umami, GlitchTip, Web
* Push, Bug Report) under one route so each existing component stays the
* source of truth for its own settings while admins still get a single
* landing page mirroring the v1.4 layout.
* Wraps the standalone integration sub-sections (Umami, GlitchTip, Web Push)
* under one route so each existing component stays the source of truth for its
* own settings while admins still get a single landing page.
*/
import { BugReportSection } from "./bug-report-section";
import { GlitchtipSection } from "./glitchtip-section";
import { UmamiSection } from "./umami-section";
import { WebPushVapidSection } from "./web-push-vapid-section";
@@ -20,7 +18,6 @@ export function IntegrationsGroupSection() {
<UmamiSection />
<GlitchtipSection />
<WebPushVapidSection />
<BugReportSection />
</div>
);
}
-1
View File
@@ -25,7 +25,6 @@ export const ADMIN_SECTION_SLUGS = [
// (was stacked under Coach). It gates every module, not just the coach,
// so it reads better next to Coach than buried inside it.
"module-availability",
"feedback",
"reminders",
"users",
// v1.16.0 — registration invites get their own section. They started
@@ -6,7 +6,6 @@ import {
AlertTriangle,
Bell,
BellRing,
Bug,
Clock,
Cog,
Database,
@@ -160,14 +159,6 @@ export function SystemStatusSection() {
className="text-dracula-green"
/>
)}
{status.integrations.bugReport && (
<StatusItem
icon={Bug}
label={t("admin.integrationBugReport")}
value={t("admin.configured")}
className="text-dracula-green"
/>
)}
{/* v1.4.27 R5 — offline GeoLite2 availability. Renders only
when /api/version returns the new field so legacy
responses do not produce a placeholder row. */}
-81
View File
@@ -1,81 +0,0 @@
"use client";
/**
* Client-side feature-flag context — exposes admin-managed app settings to
* layout components so nav surfaces can hide entries when the admin flips a
* toggle.
*
* Today this only carries the `bugReportEnabled` flag (the "Bug Report"
* sidebar entry, the bottom-nav, the topbar dropdown, and the user-facing
* "Report bug" buttons inside `<ErrorDetails>` all gate on it). Add new
* flags here when more admin-managed feature toggles need to reach the
* client.
*
* Source: `GET /api/bugreport/status` already returns `{ enabled }` for any
* authenticated user. We piggy-back on that endpoint instead of standing up
* a parallel `/api/app-settings` route — the data is identical and the
* caching / rate-limit story is already solved upstream.
*
* Defaults to "all flags ON" so unauthenticated layouts (login, register)
* and the brief window before the query resolves don't flicker the entry
* out from underneath the user. The admin's "off" state only takes effect
* once the data has actually arrived.
*/
import { createContext, useContext, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
export interface AppSettings {
/** Whether the admin has enabled in-app bug-report / feedback submission. */
bugReportEnabled: boolean;
}
const DEFAULT_SETTINGS: AppSettings = {
bugReportEnabled: true,
};
const AppSettingsContext = createContext<AppSettings>(DEFAULT_SETTINGS);
export function useAppSettings(): AppSettings {
return useContext(AppSettingsContext);
}
interface BugReportStatusPayload {
configured: boolean;
enabled: boolean;
isAdmin: boolean;
}
/**
* Fetches `bugReportEnabled` once per session and exposes it via context. The
* provider lives inside `Providers` so anything beneath the React tree —
* including the global nav surfaces — can observe the flag without
* each component re-fetching the same status payload.
*/
export function AppSettingsProvider({ children }: { children: ReactNode }) {
const { isAuthenticated } = useAuth();
const { data } = useQuery({
queryKey: queryKeys.bugreportStatus(),
enabled: isAuthenticated,
queryFn: async () => {
return apiGet<BugReportStatusPayload>("/api/bugreport/status");
},
staleTime: 5 * 60 * 1000,
});
// Default to ON when the query is still loading, has errored, or the user
// is unauthenticated — the nav must not flicker the entry out under the
// user's cursor on a slow network.
const settings: AppSettings = {
bugReportEnabled: data?.enabled ?? true,
};
return (
<AppSettingsContext.Provider value={settings}>
{children}
</AppSettingsContext.Provider>
);
}
+1 -15
View File
@@ -1,11 +1,9 @@
"use client";
import { useState } from "react";
import { Copy, CheckCircle2, RotateCcw, Bug } from "lucide-react";
import Link from "next/link";
import { Copy, CheckCircle2, RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { useAppSettings } from "@/components/app-settings-provider";
export interface ErrorDetailsProps {
/** The error object, whether it's a runtime Error or a typed API error. */
@@ -16,8 +14,6 @@ export interface ErrorDetailsProps {
requestId?: string | null;
/** Extra fields to include in the diagnostic payload. */
context?: Record<string, unknown>;
/** Override the "Report bug" link target. */
reportHref?: string;
}
/**
@@ -30,10 +26,8 @@ export function ErrorDetails({
reset,
requestId,
context,
reportHref = "/bugreport",
}: ErrorDetailsProps) {
const { t, locale } = useTranslations();
const { bugReportEnabled } = useAppSettings();
const [copied, setCopied] = useState(false);
async function handleCopy() {
@@ -102,14 +96,6 @@ export function ErrorDetails({
)}
{copied ? t("common.copied") : t("common.copyDetails")}
</Button>
{bugReportEnabled && (
<Button asChild variant="outline">
<Link href={reportHref}>
<Bug className="h-4 w-4" />
{t("common.reportIssue")}
</Link>
</Button>
)}
</div>
</div>
);
@@ -74,28 +74,14 @@ describe("nav-model utility tail (N-1 — one shared list)", () => {
}
});
it("lists the shared utility surfaces (Bug Report, Settings, Notifications)", () => {
it("lists the shared utility surfaces (Settings, Notifications)", () => {
const hrefs = NAV_UTILITY_DESTINATIONS.map((d) => d.href);
expect(hrefs).toContain("/bugreport");
expect(hrefs).toContain("/settings/account");
expect(hrefs).toContain("/notifications");
});
it("drops the bug-report entry unless the operator flag is on", () => {
const off = visibleUtilityDestinations(false).map((d) => d.href);
const on = visibleUtilityDestinations(true).map((d) => d.href);
expect(off).not.toContain("/bugreport");
expect(on).toContain("/bugreport");
// Non-gated entries are present either way.
expect(off).toContain("/settings/account");
expect(off).toContain("/notifications");
});
it("keeps a stable order (Bug Report → Settings → Notifications)", () => {
const hrefs = visibleUtilityDestinations(true).map((d) => d.href);
expect(hrefs.indexOf("/bugreport")).toBeLessThan(
hrefs.indexOf("/settings/account"),
);
it("keeps a stable order (Settings → Notifications)", () => {
const hrefs = visibleUtilityDestinations().map((d) => d.href);
expect(hrefs.indexOf("/settings/account")).toBeLessThan(
hrefs.indexOf("/notifications"),
);
@@ -178,16 +164,13 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => {
it("is exactly the visible feature destinations minus the primary slots, then the utility tail", () => {
const opts = {
modules: { cycle: false } as const,
bugReportEnabled: false,
};
const hub = mobileMoreHubDestinations(opts).map((d) => d.href);
const expectedFeatures = visibleNavDestinations(opts.modules)
.map((d) => d.href)
.filter((href) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(href));
const expectedTail = visibleUtilityDestinations(opts.bugReportEnabled).map(
(d) => d.href,
);
const expectedTail = visibleUtilityDestinations().map((d) => d.href);
expect(hub).toEqual([...expectedFeatures, ...expectedTail]);
});
@@ -195,7 +178,6 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => {
it("never contains a primary slot (Home / Meds / Insights)", () => {
const hub = mobileMoreHubDestinations({
modules: { cycle: true },
bugReportEnabled: true,
}).map((d) => d.href);
for (const slot of BOTTOM_NAV_PRIMARY_SLOT_HREFS) {
expect(hub).not.toContain(slot);
@@ -205,33 +187,27 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => {
it("keeps the feature destinations and the utility tail reachable in the hub", () => {
const hub = mobileMoreHubDestinations({
modules: { cycle: false },
bugReportEnabled: true,
}).map((d) => d.href);
// Feature surfaces that left the always-visible strip stay reachable.
expect(hub).toContain("/measurements");
expect(hub).toContain("/mood");
expect(hub).toContain("/checkups");
// The shared utility tail rides at the end.
expect(hub).toContain("/bugreport");
expect(hub).toContain("/settings/account");
expect(hub).toContain("/notifications");
});
it("gates Cycle by the module map and Bug Report by the operator flag, same as the sidebar", () => {
it("gates Cycle by the module map, same as the sidebar", () => {
const off = mobileMoreHubDestinations({
modules: { cycle: false, mood: false },
bugReportEnabled: false,
}).map((d) => d.href);
const on = mobileMoreHubDestinations({
modules: { cycle: true, mood: true },
bugReportEnabled: true,
}).map((d) => d.href);
expect(off).not.toContain("/cycle");
expect(off).not.toContain("/mood");
expect(off).not.toContain("/bugreport");
expect(on).toContain("/cycle");
expect(on).toContain("/mood");
expect(on).toContain("/bugreport");
});
});
@@ -43,13 +43,6 @@ vi.mock("@/components/providers", () => ({
}),
}));
// The bug-report flag — each test mutates `mockSettingsRef.value` before
// rendering so the sidebar reads the desired state.
const mockSettingsRef = { value: { bugReportEnabled: true } };
vi.mock("@/components/app-settings-provider", () => ({
useAppSettings: () => mockSettingsRef.value,
}));
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { SidebarNav } from "../sidebar-nav";
@@ -58,17 +51,14 @@ import { visibleUtilityDestinations } from "../nav-model";
function render({
pathname = "/",
bugReportEnabled = true,
role = "USER" as "USER" | "ADMIN",
modules = {} as Record<string, boolean>,
}: {
pathname?: string;
bugReportEnabled?: boolean;
role?: "USER" | "ADMIN";
modules?: Record<string, boolean>;
} = {}) {
mockPathnameRef.value = pathname;
mockSettingsRef.value = { bugReportEnabled };
mockUserRef.value = { ...mockUserRef.value, role, modules };
return renderToStaticMarkup(
// The nav reads `useQueryClient()` for the medications intent
@@ -82,21 +72,6 @@ function render({
);
}
describe("<SidebarNav> bug-report toggle", () => {
it("renders the Bug Report entry when the admin flag is enabled", () => {
const html = render({ bugReportEnabled: true });
expect(html).toContain('href="/bugreport"');
// i18n string for the entry — sanity check that the link is the actual
// bug-report nav entry, not e.g. an error-page report-bug button.
expect(html).toContain("Bug Report");
});
it("hides the Bug Report entry when the admin flag is disabled", () => {
const html = render({ bugReportEnabled: false });
expect(html).not.toContain('href="/bugreport"');
});
});
describe("<SidebarNav> cycle entry gate (v1.15.0 / v1.18.0 module map)", () => {
it("renders the Cycle entry when the cycle module is enabled", () => {
const html = render({ modules: { cycle: true } });
@@ -212,28 +187,17 @@ describe("<SidebarNav> shared utility tail (v1.17.1 N-1 parity guard)", () => {
// therefore the shared list minus `/notifications`. Asserting they all
// render straight from the shared list is the parity net that keeps the
// sidebar footer from drifting away from the mobile More-hub tail.
const footerUtilities = (bugReportEnabled: boolean) =>
visibleUtilityDestinations(bugReportEnabled).filter(
(d) => d.href !== "/notifications",
);
const footerUtilities = () =>
visibleUtilityDestinations().filter((d) => d.href !== "/notifications");
it("renders every footer utility destination straight from the shared list", () => {
const html = render({ bugReportEnabled: true });
for (const dest of footerUtilities(true)) {
const html = render();
for (const dest of footerUtilities()) {
expect(html).toContain(`href="${dest.href}"`);
}
// Bug Report and Settings are the footer utilities; both are present.
expect(html).toContain('href="/bugreport"');
// Settings is the footer utility entry; it is present.
expect(html).toContain('href="/settings/account"');
});
it("drops the bug-report utility entry when the operator flag is off", () => {
const html = render({ bugReportEnabled: false });
for (const dest of footerUtilities(false)) {
expect(html).toContain(`href="${dest.href}"`);
}
expect(html).not.toContain('href="/bugreport"');
});
});
describe("<SidebarNav> admin entry mirrors Settings (no sub-item expansion)", () => {
+2 -2
View File
@@ -261,8 +261,8 @@ export function AuthShell({
>
{/*
v1.4.33 IW9 — container normalised on `max-w-screen-xl`
(1280 px) so dashboard / settings / admin / bugreport all
hit the same content frame. Pre-v1.4.33 the dashboard
(1280 px) so dashboard / settings / admin all hit the
same content frame. Pre-v1.4.33 the dashboard
shell capped at `max-w-[76.8rem]` (1228 px) while the
settings/admin shells used `max-w-screen-xl`, producing a
52 px lateral wobble on every route switch. Same audit
+3 -6
View File
@@ -18,7 +18,6 @@ import {
} from "@/components/layout/nav-model";
import type { ModuleKey } from "@/lib/modules/registry";
import { useMemo, useState } from "react";
import { useAppSettings } from "@/components/app-settings-provider";
import { useAuth } from "@/hooks/use-auth";
import {
Sheet,
@@ -83,21 +82,19 @@ export function BottomNav() {
// v1.16.7 — touch intent on the medications tab starts the list
// request before the navigation commits (see sidebar-nav).
const medsIntent = medicationsPrefetchIntentProps(queryClient);
const { bugReportEnabled } = useAppSettings();
const [moreOpen, setMoreOpen] = useState(false);
const [captureOpen, setCaptureOpen] = useState(false);
// v1.17.1 (F-1) — the More hub is the model-computed hub: every visible
// feature destination that isn't a primary slot, plus the shared utility
// tail. Cycle + Bug Report are gated by the same flags the sidebar uses,
// so the two surfaces gate identically and cannot drift.
// tail. Cycle is gated by the same flag the sidebar uses, so the two
// surfaces gate identically and cannot drift.
const moreHub = useMemo<ReadonlyArray<NavLink>>(
() =>
mobileMoreHubDestinations({
modules: user?.modules,
bugReportEnabled,
}),
[user?.modules, bugReportEnabled],
[user?.modules],
);
// v1.18.0 — drop a module-gated primary slot (Insights) when the account
+17 -34
View File
@@ -1,7 +1,6 @@
import {
Activity,
Bell,
Bug,
Droplets,
FlaskConical,
Home,
@@ -168,17 +167,15 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
/**
* v1.17.1 (F-1 residue) — the shared UTILITY tail.
*
* Bug Report, Settings and Notifications are reachable on both bars but are
* not feature destinations: on desktop they live in the sidebar footer +
* avatar menu, on mobile at the tail of the "More" hub. They used to be a
* second hand-curated list on each bar — the exact drift the one-model
* contract above set out to kill, just pushed down a level. This list is
* the single source both bars consume for the tail, so the two surfaces
* can no longer disagree on which utility links exist or in which order.
* Settings and Notifications are reachable on both bars but are not feature
* destinations: on desktop they live in the sidebar footer + avatar menu, on
* mobile at the tail of the "More" hub. They used to be a second hand-curated
* list on each bar — the exact drift the one-model contract above set out to
* kill, just pushed down a level. This list is the single source both bars
* consume for the tail, so the two surfaces can no longer disagree on which
* utility links exist or in which order.
*
* Order is the footer/hub order: Bug Report (gated) → Settings →
* Notifications. `bugReportGated` entries drop when the operator flag is
* off, exactly as the feature list drops cycle-gated entries.
* Order is the footer/hub order: Settings → Notifications.
*
* Admin is intentionally NOT here: it is a role-gated, desktop-sidebar-only
* surface (the mobile bar never exposes it), so it is not a shared tail
@@ -189,33 +186,20 @@ export interface NavUtilityDestination {
/** i18n key under the `nav.*` namespace. */
tKey: string;
icon: LucideIcon;
/** Drop the entry unless the operator's bug-report flag is on. */
bugReportGated?: boolean;
}
export const NAV_UTILITY_DESTINATIONS: ReadonlyArray<NavUtilityDestination> = [
{
href: "/bugreport",
tKey: "nav.bugreport",
icon: Bug,
bugReportGated: true,
},
{ href: "/settings/account", tKey: "nav.settings", icon: Settings },
{ href: "/notifications", tKey: "nav.notifications", icon: Bell },
];
/**
* The utility tail visible to this account — drops the bug-report entry
* when the operator flag is off. Both bars consume this for their tail
* (the sidebar footer + avatar menu, the bottom-nav More hub) so the two
* The utility tail visible to this account. Both bars consume this for their
* tail (the sidebar footer + avatar menu, the bottom-nav More hub) so the two
* surfaces share one definition of the utility links.
*/
export function visibleUtilityDestinations(
bugReportEnabled: boolean | undefined,
): NavUtilityDestination[] {
return NAV_UTILITY_DESTINATIONS.filter(
(d) => !d.bugReportGated || bugReportEnabled === true,
);
export function visibleUtilityDestinations(): NavUtilityDestination[] {
return [...NAV_UTILITY_DESTINATIONS];
}
/**
@@ -276,19 +260,18 @@ export interface MobileMoreHubEntry {
/**
* The ordered "More" hub for the mobile bottom-nav: every visible feature
* destination that isn't a primary slot, in model order, followed by the
* shared utility tail (Bug Report behind its flag, Settings, Notifications).
* The desktop sidebar renders the same feature list inline and consumes the
* same utility tail in its footer + avatar menu, so the two bars cannot
* drift into two hand-curated lists.
* shared utility tail (Settings, Notifications). The desktop sidebar renders
* the same feature list inline and consumes the same utility tail in its
* footer + avatar menu, so the two bars cannot drift into two hand-curated
* lists.
*/
export function mobileMoreHubDestinations(opts: {
modules: ModuleVisibilityMap | undefined;
bugReportEnabled: boolean | undefined;
}): MobileMoreHubEntry[] {
const features = visibleNavDestinations(opts.modules)
.filter((d) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(d.href))
.map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon }));
const tail = visibleUtilityDestinations(opts.bugReportEnabled).map((d) => ({
const tail = visibleUtilityDestinations().map((d) => ({
href: d.href,
tKey: d.tKey,
icon: d.icon,
+2 -6
View File
@@ -26,7 +26,6 @@ import { cn } from "@/lib/utils";
import { Logo } from "@/components/ui/logo";
import { useAuth, useLogout } from "@/hooks/use-auth";
import { useTheme } from "@/components/providers";
import { useAppSettings } from "@/components/app-settings-provider";
import { useTranslations } from "@/lib/i18n/context";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
@@ -229,7 +228,6 @@ export function SidebarNav() {
// list request before the navigation commits, so the due-time cells
// hydrate from a warm cache instead of serialising behind the chunk.
const medsIntent = medicationsPrefetchIntentProps(queryClient);
const { bugReportEnabled } = useAppSettings();
const isAdmin = user?.role === "ADMIN";
// Match `/admin` exactly or any `/admin/...` sub-route for active-link
// styling. Plain `startsWith("/admin")` would also flip for a
@@ -276,10 +274,8 @@ export function SidebarNav() {
// role-gated, sidebar-only surface and is inserted separately below.
const footerUtilityItems = useMemo(
() =>
visibleUtilityDestinations(bugReportEnabled).filter(
(d) => d.href !== "/notifications",
),
[bugReportEnabled],
visibleUtilityDestinations().filter((d) => d.href !== "/notifications"),
[],
);
function isUtilityActive(href: string) {
+6 -9
View File
@@ -17,7 +17,6 @@ import {
import { I18nProvider } from "@/lib/i18n/context";
import type { Locale } from "@/lib/i18n/config";
import { Toaster } from "@/components/ui/sonner";
import { AppSettingsProvider } from "@/components/app-settings-provider";
import { VersionPoller } from "@/components/version-poller";
import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar";
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
@@ -211,14 +210,12 @@ export function Providers({
initialLocale={initialLocale}
initialMessages={initialMessages}
>
<AppSettingsProvider>
<QueryPersistenceBridge />
<DashboardSnapshotPreloader />
{children}
<Toaster position="bottom-right" richColors />
<VersionPoller />
<ServiceWorkerRegistrar />
</AppSettingsProvider>
<QueryPersistenceBridge />
<DashboardSnapshotPreloader />
{children}
<Toaster position="bottom-right" richColors />
<VersionPoller />
<ServiceWorkerRegistrar />
</I18nProvider>
</ThemeProvider>
</QueryClientProvider>