mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Re-enable the /admin axe-core spec that phase-3 parked while phase-4b
restructured the admin into per-section dynamic routes. The fixme is
gone, /admin/system-status and /admin/users are added as representative
sub-route audits, and the violations the original audit surfaced are
fixed:
- `src/components/admin/_shared.tsx`: drop the duplicate
`<PasswordInput>` whose toggle button had no accessible name.
Re-export the canonical `<PasswordInput>` from
`src/components/settings/password-input.tsx` (added in phase-3) which
already carries the `aria-label="Show password" / "Hide password"`
pair. Affects `/admin/integrations` (bug-report token, web-push VAPID
private key) and `/admin/users` (password-reset dialog).
- `src/components/admin/user-management-section.tsx`: icon-only edit /
reset-password / force-logout buttons now carry `aria-label` (axe
`button-name` is `serious`); the leading icon is `aria-hidden` so the
accessible name is just the action verb.
- `src/components/admin/status-card-grid.tsx`: drop the placeholder
`{ label: "—", value: "" }` tuple from the audit-log card. An empty
`<dd>` next to a "—" `<dt>` is not navigable and the `<dl>` was the
most likely source of an axe violation on `/admin`. The 3-column grid
still works with two metrics; the empty cell at the end is unrendered
because there is no `<div>` for it.
- `src/components/admin/__tests__/status-card-grid.test.tsx`: rework
the 3-metric assertion to allow 1..3 metrics with non-empty,
non-em-dash labels so the audit-log change doesn't regress.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
268 lines
6.6 KiB
TypeScript
268 lines
6.6 KiB
TypeScript
"use client";
|
|
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { toast } from "sonner";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { PasswordInput as SettingsPasswordInput } from "@/components/settings/password-input";
|
|
|
|
/**
|
|
* Re-export the canonical password input so admin sections have access to
|
|
* the same accessible (aria-labelled) toggle the settings sections use.
|
|
* The earlier in-file copy lacked an aria-label which surfaced as a
|
|
* `button-name` violation on `/admin/integrations` and
|
|
* `/admin/users` (axe-core, WCAG 2.1.2).
|
|
*/
|
|
export const PasswordInput = SettingsPasswordInput;
|
|
|
|
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;
|
|
bugReportEnabled: 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 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;
|
|
},
|
|
});
|
|
}
|