mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
feat(medications,coach,admin,perf): era corrections, a truthful weekly status and one card language everywhere
The dashboard snapshot fires the moment login succeeds instead of waiting for the page chunk (start ~670 to ~200 ms throttled). Guided follow-ups are personalised from the user's snapshot and an answer gets the coach's streamed reaction before the next question. Archived schedule eras can be corrected through a superseding revision that keeps the original as an audit trail, era writes serialise per medication, and duplicate dose times collapse in validation. A rolling medication whose next dose is tomorrow can no longer read as heavily overdue — the card status derives strictly from the served next-due — and the adherence bars keep a fixed geometry in every state. The server-wide assistant key gains its admin card, the one duplicated coach preference collapses onto a single owner, twenty-seven admin and settings card headers move onto the shared pattern with sixty containers normalised, and the remaining hydration warnings are gone.
This commit is contained in:
1 parent
a4b3d2fcd0
commit
40f7196c20
115 files changed
+3014
-1084
No files matched your search
@@ -24,10 +24,12 @@ vi.mock("next/navigation", () => ({
|
||||
// The shell gates its own frame on the confirmed ADMIN role — a
|
||||
// non-admin (or a still-loading auth state) must not see the section
|
||||
// nav while AuthShell's redirect effect is catching up.
|
||||
const mockUserRef = { value: { role: "ADMIN", username: "op" } as {
|
||||
role: string;
|
||||
username: string;
|
||||
} | null };
|
||||
const mockUserRef = {
|
||||
value: { role: "ADMIN", username: "op" } as {
|
||||
role: string;
|
||||
username: string;
|
||||
} | null,
|
||||
};
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: mockUserRef.value }),
|
||||
}));
|
||||
|
||||
@@ -374,9 +374,7 @@ describe("carrierShortLabel — DACH carrier folding (v1.4.27 B3)", () => {
|
||||
});
|
||||
|
||||
it("folds Telefónica + O2 variants down to 'O2'", () => {
|
||||
expect(
|
||||
carrierShortLabel("Telefónica Germany GmbH & Co. OHG"),
|
||||
).toBe("O2");
|
||||
expect(carrierShortLabel("Telefónica Germany GmbH & Co. OHG")).toBe("O2");
|
||||
expect(carrierShortLabel("Telefonica Germany Online Services")).toBe("O2");
|
||||
expect(carrierShortLabel("O2 Deutschland")).toBe("O2");
|
||||
});
|
||||
@@ -384,6 +382,8 @@ describe("carrierShortLabel — DACH carrier folding (v1.4.27 B3)", () => {
|
||||
it("returns the raw organisation string for unknown carriers", () => {
|
||||
expect(carrierShortLabel("Google LLC")).toBe("Google LLC");
|
||||
expect(carrierShortLabel("Amazon.com, Inc.")).toBe("Amazon.com, Inc.");
|
||||
expect(carrierShortLabel("Hetzner Online GmbH")).toBe("Hetzner Online GmbH");
|
||||
expect(carrierShortLabel("Hetzner Online GmbH")).toBe(
|
||||
"Hetzner Online GmbH",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -120,9 +120,12 @@ describe("UserManagementSection — mobile responsive", () => {
|
||||
expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*sm:p-6/);
|
||||
});
|
||||
|
||||
it("stacks header rows on mobile so filter pills don't collide with title", () => {
|
||||
it("wraps the filter pills below the title on narrow viewports", () => {
|
||||
const html = render();
|
||||
// Header carries `flex-col` at base + `sm:flex-row` at small+.
|
||||
expect(html).toMatch(/class="[^"]*\bflex-col\b[^"]*sm:flex-row/);
|
||||
// The shared SettingsCardHeader title row wraps, so the status slot
|
||||
// (filter pills) reflows under the title instead of colliding with it.
|
||||
expect(html).toMatch(
|
||||
/class="[^"]*\bflex-wrap\b[^"]*items-start justify-between/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Cpu, Fingerprint, Globe, KeyRound } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { PasswordInput as SharedPasswordInput } from "@/components/ui/password-input";
|
||||
@@ -160,6 +161,18 @@ export const FEEDBACK_STATUS_TABS: FeedbackStatusType[] = [
|
||||
"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. */
|
||||
export function ConfiguredBadge() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("admin.configured")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
|
||||
@@ -261,9 +261,9 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
// v1.4.25 W8 — chip strip is the primary mobile-admin
|
||||
// navigation surface. Pad to WCAG 2.5.5 44 px so the
|
||||
// chips can be tapped without zoom on a Pixel-5.
|
||||
"flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
|
||||
// navigation surface. Pad to WCAG 2.5.5 44 px so the
|
||||
// chips can be tapped without zoom on a Pixel-5.
|
||||
"flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
|
||||
isActive
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border text-foreground hover:bg-accent",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Sparkles, Loader2 } from "lucide-react";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
@@ -66,7 +67,7 @@ export function AiQualitySection() {
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="bg-card border-border flex items-center gap-2 rounded-xl border p-6">
|
||||
<div className="bg-card border-border flex items-center gap-2 rounded-xl border p-4 sm:p-6">
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.aiQuality.loading")}
|
||||
@@ -90,14 +91,12 @@ export function AiQualitySection() {
|
||||
|
||||
if (!summary || summary.buckets.length === 0) {
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="text-dracula-purple h-5 w-5" />
|
||||
<div className="text-lg font-semibold">
|
||||
{t("admin.aiQuality.title")}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-3 text-sm">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Sparkles}
|
||||
title={t("admin.aiQuality.title")}
|
||||
/>
|
||||
<p className="text-muted-foreground mt-3 pl-7 text-sm">
|
||||
{t("admin.aiQuality.empty")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -105,23 +104,21 @@ export function AiQualitySection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="text-dracula-purple h-5 w-5" />
|
||||
<div className="text-lg font-semibold">
|
||||
{t("admin.aiQuality.title")}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.aiQuality.windowLabel", {
|
||||
days: String(summary.windowDays),
|
||||
})}
|
||||
{" · "}
|
||||
{t("admin.aiQuality.generatedAtLabel")}{" "}
|
||||
{fmt.dateTime(summary.generatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Sparkles}
|
||||
title={t("admin.aiQuality.title")}
|
||||
status={
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.aiQuality.windowLabel", {
|
||||
days: String(summary.windowDays),
|
||||
})}
|
||||
{" · "}
|
||||
{t("admin.aiQuality.generatedAtLabel")}{" "}
|
||||
{fmt.dateTime(summary.generatedAt)}
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" data-slot="ai-quality-table">
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { KeyRound, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { PasswordInput } from "./_shared";
|
||||
|
||||
/**
|
||||
* v1.16.6 — operator panel for the server-wide AI key.
|
||||
*
|
||||
* The backing endpoint (`/api/admin/ai-settings`, admin-cookie-only) has
|
||||
* existed since the `admin-openai` chain fallback shipped, but it never had
|
||||
* an operator UI — configuring the shared key took a manual API call. This
|
||||
* card is its home, beside the assistant feature flags.
|
||||
*
|
||||
* Semantics worth keeping visible to the operator:
|
||||
* - A user's own key (BYOK / Codex OAuth / local) always wins; the server
|
||||
* key sits at the END of the provider chain as the shared fallback.
|
||||
* - Egress over the server key is consent-gated per user
|
||||
* (`ConsentRequiredError` in `src/lib/ai/consent-guard.ts`) — setting a
|
||||
* key here never silently forwards anyone's health data.
|
||||
* - The base URL is locked server-side to HTTPS + a hostname allowlist
|
||||
* (`ADMIN_AI_BASE_URL_ALLOWLIST` extends it).
|
||||
*/
|
||||
|
||||
interface AiServerKeyResponse {
|
||||
hasKey: boolean;
|
||||
keyPreview: string | null;
|
||||
model: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export function AiServerKeySection() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: queryKeys.adminAiServerKey(),
|
||||
queryFn: () => apiGet<AiServerKeyResponse>("/api/admin/ai-settings"),
|
||||
});
|
||||
|
||||
const [keyDraft, setKeyDraft] = useState("");
|
||||
const [modelDraft, setModelDraft] = useState<string | null>(null);
|
||||
const [baseUrlDraft, setBaseUrlDraft] = useState<string | null>(null);
|
||||
|
||||
const modelValue = modelDraft ?? settings?.model ?? "";
|
||||
const baseUrlValue = baseUrlDraft ?? settings?.baseUrl ?? "";
|
||||
const configured = settings?.hasKey ?? false;
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (payload: Record<string, unknown>) =>
|
||||
apiPut<AiServerKeyResponse>("/api/admin/ai-settings", payload),
|
||||
onSuccess: () => {
|
||||
setKeyDraft("");
|
||||
setModelDraft(null);
|
||||
setBaseUrlDraft(null);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.adminAiServerKey(),
|
||||
});
|
||||
toast.success(t("admin.aiServerKey.saved"));
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("admin.aiServerKey.error"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSave() {
|
||||
const payload: Record<string, unknown> = {
|
||||
model: modelValue,
|
||||
baseUrl: baseUrlValue,
|
||||
};
|
||||
if (keyDraft.trim().length > 0) {
|
||||
payload.apiKey = keyDraft.trim();
|
||||
}
|
||||
save.mutate(payload);
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
save.mutate({ apiKey: "" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="admin-ai-server-key"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={KeyRound}
|
||||
title={t("admin.aiServerKey.title")}
|
||||
description={
|
||||
<>
|
||||
<p>{t("admin.aiServerKey.description")}</p>
|
||||
<p>{t("admin.aiServerKey.byokHint")}</p>
|
||||
<p>{t("admin.aiServerKey.consentHint")}</p>
|
||||
</>
|
||||
}
|
||||
status={
|
||||
<Badge variant={configured ? "default" : "outline"}>
|
||||
{configured
|
||||
? t("admin.aiServerKey.statusConfigured")
|
||||
: t("admin.aiServerKey.statusNotConfigured")}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-ai-server-key-input">
|
||||
{t("admin.aiServerKey.keyLabel")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="admin-ai-server-key-input"
|
||||
value={keyDraft}
|
||||
onChange={(e) => setKeyDraft(e.target.value)}
|
||||
placeholder={
|
||||
configured && settings?.keyPreview
|
||||
? t("admin.aiServerKey.keyPlaceholderSet", {
|
||||
preview: settings.keyPreview,
|
||||
})
|
||||
: t("admin.aiServerKey.keyPlaceholder")
|
||||
}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-ai-server-key-model">
|
||||
{t("admin.aiServerKey.modelLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-ai-server-key-model"
|
||||
value={modelValue}
|
||||
onChange={(e) => setModelDraft(e.target.value)}
|
||||
placeholder="gpt-4o"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-ai-server-key-base-url">
|
||||
{t("admin.aiServerKey.baseUrlLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-ai-server-key-base-url"
|
||||
value={baseUrlValue}
|
||||
onChange={(e) => setBaseUrlDraft(e.target.value)}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={save.isPending}
|
||||
data-slot="admin-ai-server-key-save"
|
||||
>
|
||||
{save.isPending && (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("admin.aiServerKey.save")}
|
||||
</Button>
|
||||
{configured && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRemove}
|
||||
disabled={save.isPending}
|
||||
data-slot="admin-ai-server-key-remove"
|
||||
>
|
||||
{t("admin.aiServerKey.remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { type ApiTokenInfo } from "./_shared";
|
||||
@@ -157,10 +158,11 @@ export function ApiTokenOverviewSection() {
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border overflow-hidden rounded-xl border p-4 sm:p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">{t("admin.apiTokens")}</div>
|
||||
</div>
|
||||
<SettingsCardHeader
|
||||
icon={Key}
|
||||
title={t("admin.apiTokens")}
|
||||
description={t("admin.apiTokensDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
{isLoading ? (
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { WideEvent, LogLevel } from "@/lib/logging/types";
|
||||
@@ -104,7 +105,9 @@ export function AppLogPreviewSection() {
|
||||
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: queryKeys.adminAppLogs(traceId, actionFilter, level, range),
|
||||
queryFn: async () => {
|
||||
return apiGet<AppLogsResponse>(`/api/admin/app-logs?${params.toString()}`);
|
||||
return apiGet<AppLogsResponse>(
|
||||
`/api/admin/app-logs?${params.toString()}`,
|
||||
);
|
||||
},
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
@@ -112,31 +115,25 @@ export function AppLogPreviewSection() {
|
||||
const events = data?.events ?? [];
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<header className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<FileText className="text-muted-foreground mt-0.5 h-5 w-5" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("admin.section.app-logs.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.section.app-logs.processNote")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
aria-label={t("admin.section.app-logs.refresh")}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""} motion-reduce:animate-none`}
|
||||
/>
|
||||
</Button>
|
||||
</header>
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={FileText}
|
||||
title={t("admin.section.app-logs.title")}
|
||||
description={t("admin.section.app-logs.processNote")}
|
||||
status={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
aria-label={t("admin.section.app-logs.refresh")}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""} motion-reduce:animate-none`}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Input
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { SettingsToggle } from "./_shared";
|
||||
@@ -49,7 +50,9 @@ function useAssistantFlags() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.adminAssistantFlags(),
|
||||
queryFn: async () => {
|
||||
return apiGet<AssistantFlagsResponse>("/api/admin/settings/assistant-flags");
|
||||
return apiGet<AssistantFlagsResponse>(
|
||||
"/api/admin/settings/assistant-flags",
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -61,13 +64,13 @@ function useUpdateAssistantFlags() {
|
||||
mutationFn: async (
|
||||
patch: Partial<AssistantFlagsResponse["raw"]>,
|
||||
): Promise<AssistantFlagsResponse> => {
|
||||
return apiPut<AssistantFlagsResponse>("/api/admin/settings/assistant-flags", patch);
|
||||
return apiPut<AssistantFlagsResponse>(
|
||||
"/api/admin/settings/assistant-flags",
|
||||
patch,
|
||||
);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
client.setQueryData(
|
||||
queryKeys.adminAssistantFlags(),
|
||||
data,
|
||||
);
|
||||
client.setQueryData(queryKeys.adminAssistantFlags(), data);
|
||||
// Bust the runtime `/api/feature-flags` cache so the operator
|
||||
// sees the toggled surface react within the same session.
|
||||
client.invalidateQueries({ queryKey: queryKeys.featureFlags() });
|
||||
@@ -96,73 +99,72 @@ export function AssistantSection() {
|
||||
const disabledSubs = !masterOn || mutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border space-y-6 rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">{t("admin.assistant.title")}</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground pl-7 text-sm">
|
||||
{t("admin.assistant.description")}
|
||||
</p>
|
||||
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.master.title")}
|
||||
description={t("admin.assistant.master.description")}
|
||||
checked={raw?.assistantEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantEnabled: checked })
|
||||
}
|
||||
disabled={mutation.isPending}
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Sparkles}
|
||||
title={t("admin.assistant.title")}
|
||||
description={t("admin.assistant.description")}
|
||||
/>
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.master.title")}
|
||||
description={t("admin.assistant.master.description")}
|
||||
checked={raw?.assistantEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantEnabled: checked })
|
||||
}
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
|
||||
<div className="border-border space-y-4 border-t pt-4">
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.coach.title")}
|
||||
description={t("admin.assistant.coach.description")}
|
||||
checked={raw?.assistantCoachEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantCoachEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.briefing.title")}
|
||||
description={t("admin.assistant.briefing.description")}
|
||||
checked={raw?.assistantBriefingEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantBriefingEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.insightStatus.title")}
|
||||
description={t("admin.assistant.insightStatus.description")}
|
||||
checked={raw?.assistantInsightStatusEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantInsightStatusEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.correlations.title")}
|
||||
description={t("admin.assistant.correlations.description")}
|
||||
checked={raw?.assistantCorrelationsEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantCorrelationsEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.healthScoreExplainer.title")}
|
||||
description={t("admin.assistant.healthScoreExplainer.description")}
|
||||
checked={raw?.assistantHealthScoreExplainerEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({
|
||||
assistantHealthScoreExplainerEnabled: checked,
|
||||
})
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<div className="border-border space-y-4 border-t pt-4">
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.coach.title")}
|
||||
description={t("admin.assistant.coach.description")}
|
||||
checked={raw?.assistantCoachEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantCoachEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.briefing.title")}
|
||||
description={t("admin.assistant.briefing.description")}
|
||||
checked={raw?.assistantBriefingEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantBriefingEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.insightStatus.title")}
|
||||
description={t("admin.assistant.insightStatus.description")}
|
||||
checked={raw?.assistantInsightStatusEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantInsightStatusEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.correlations.title")}
|
||||
description={t("admin.assistant.correlations.description")}
|
||||
checked={raw?.assistantCorrelationsEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ assistantCorrelationsEnabled: checked })
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.healthScoreExplainer.title")}
|
||||
description={t("admin.assistant.healthScoreExplainer.description")}
|
||||
checked={raw?.assistantHealthScoreExplainerEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({
|
||||
assistantHealthScoreExplainerEnabled: checked,
|
||||
})
|
||||
}
|
||||
disabled={disabledSubs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -40,6 +40,7 @@ import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { BackupRow, BackupsList } from "@/types/backups";
|
||||
@@ -331,34 +332,34 @@ export function BackupsSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">
|
||||
{t("admin.section.backups.title")}
|
||||
</div>
|
||||
{data && (
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Database}
|
||||
title={t("admin.section.backups.title")}
|
||||
titleAccessory={
|
||||
data ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{rows.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={runBackup.isPending}
|
||||
onClick={() => runBackup.mutate()}
|
||||
className="min-h-11"
|
||||
>
|
||||
{runBackup.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<PlayCircle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("admin.section.backups.runNow")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
) : null
|
||||
}
|
||||
status={
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={runBackup.isPending}
|
||||
onClick={() => runBackup.mutate()}
|
||||
className="min-h-11"
|
||||
>
|
||||
{runBackup.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<PlayCircle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("admin.section.backups.runNow")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 pl-7 text-xs">
|
||||
{t("admin.section.backups.description")}{" "}
|
||||
{/* External docs link — Phase E will publish the matching page on
|
||||
the docs site. `noopener noreferrer` because this leaves the
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Bug, Loader2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
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 { PasswordInput, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
import {
|
||||
ConfiguredBadge,
|
||||
PasswordInput,
|
||||
useAdminSettings,
|
||||
useUpdateSettings,
|
||||
} from "./_shared";
|
||||
|
||||
export function BugReportSection() {
|
||||
const { t } = useTranslations();
|
||||
@@ -45,27 +50,15 @@ export function BugReportSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bug className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("admin.bugReportGithub")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{configured && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("admin.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.bugReportGithubDescription")}
|
||||
</p>
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Bug}
|
||||
title={t("admin.bugReportGithub")}
|
||||
description={t("admin.bugReportGithubDescription")}
|
||||
status={configured ? <ConfiguredBadge /> : null}
|
||||
/>
|
||||
|
||||
<div className="bg-muted/40 mt-4 flex items-center justify-between gap-3 rounded-lg p-3">
|
||||
<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")}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2, Sparkles } from "lucide-react";
|
||||
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
@@ -69,25 +70,23 @@ export function CoachFeedbackSection() {
|
||||
// /admin/coach-feedback. Mirrors the canonical structure used by
|
||||
// <SystemStatusSection> (header outside the fetch-state branch).
|
||||
return (
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="text-dracula-purple h-5 w-5" />
|
||||
<div className="text-lg font-semibold">
|
||||
{t("admin.coachFeedback.title")}
|
||||
</div>
|
||||
</div>
|
||||
{hasData && summary && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.coachFeedback.windowLabel", {
|
||||
days: String(summary.windowDays),
|
||||
})}
|
||||
{" · "}
|
||||
{t("admin.coachFeedback.generatedAtLabel")}{" "}
|
||||
{fmt.dateTime(summary.generatedAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Sparkles}
|
||||
title={t("admin.coachFeedback.title")}
|
||||
status={
|
||||
hasData && summary ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.coachFeedback.windowLabel", {
|
||||
days: String(summary.windowDays),
|
||||
})}
|
||||
{" · "}
|
||||
{t("admin.coachFeedback.generatedAtLabel")}{" "}
|
||||
{fmt.dateTime(summary.generatedAt)}
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{query.isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -59,7 +59,7 @@ export function DangerZoneSection() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-destructive/5 border-destructive/30 rounded-xl border p-6">
|
||||
<div className="bg-destructive/5 border-destructive/30 rounded-xl border p-4 sm:p-6">
|
||||
{/* v1.4.19 A8 / F-08: page header `admin.section.danger-zone.*`
|
||||
already names the section, so the card-level title was a
|
||||
duplicate of the page title. The destructive icon stays so
|
||||
|
||||
@@ -54,7 +54,9 @@ export function FeedbackInboxSection() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.adminFeedback(activeStatus),
|
||||
queryFn: async () => {
|
||||
return apiGet<FeedbackListResponse>(`/api/admin/feedback?status=${activeStatus}&limit=100`);
|
||||
return apiGet<FeedbackListResponse>(
|
||||
`/api/admin/feedback?status=${activeStatus}&limit=100`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -65,7 +67,7 @@ export function FeedbackInboxSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<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
|
||||
@@ -287,7 +289,9 @@ function FeedbackDetailDialog({
|
||||
|
||||
const publish = useMutation({
|
||||
mutationFn: async () => {
|
||||
return apiPost<{ issueUrl: string }>(`/api/admin/feedback/${item.id}/github`);
|
||||
return apiPost<{ issueUrl: string }>(
|
||||
`/api/admin/feedback/${item.id}/github`,
|
||||
);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setIssueUrl(data.issueUrl);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Settings } from "lucide-react";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
@@ -25,12 +26,13 @@ export function GeneralSettingsSection() {
|
||||
const currentDefaultTz = settings?.defaultUserTimezone ?? "";
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">{t("admin.appSettings")}</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Settings}
|
||||
title={t("admin.appSettings")}
|
||||
description={t("admin.appSettingsDescription")}
|
||||
/>
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<SettingsToggle
|
||||
label={t("admin.registrationEnabled")}
|
||||
description={t("admin.registrationEnabledDescription")}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SettingsToggle,
|
||||
useAdminSettings,
|
||||
useUpdateSettings,
|
||||
ConfiguredBadge,
|
||||
} from "./_shared";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
@@ -68,25 +69,15 @@ export function GlitchtipSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.glitchtipTitle")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{configured && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("admin.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.glitchtipDescription")}
|
||||
</p>
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={AlertTriangle}
|
||||
title={t("admin.glitchtipTitle")}
|
||||
description={t("admin.glitchtipDescription")}
|
||||
status={configured ? <ConfiguredBadge /> : null}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="mt-4 space-y-3 pl-7">
|
||||
<SettingsToggle
|
||||
label={t("admin.glitchtipEnabled")}
|
||||
icon={AlertTriangle}
|
||||
|
||||
@@ -314,12 +314,15 @@ export function InviteTokensSection() {
|
||||
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-start sm:justify-between sm:p-6">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Ticket className="text-muted-foreground h-5 w-5" aria-hidden="true" />
|
||||
<Ticket
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h2 id="admin-invites-title" className="text-lg font-semibold">
|
||||
{t("admin.invites.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 pl-7 text-sm">
|
||||
<p className="text-muted-foreground mt-1 pl-7 text-xs">
|
||||
{t("admin.invites.description")}
|
||||
</p>
|
||||
<p className="text-muted-foreground/80 mt-1 flex items-center gap-1.5 pl-7 text-xs">
|
||||
@@ -559,79 +562,79 @@ export function InviteTokensSection() {
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<fieldset>
|
||||
<legend className="text-sm font-medium">
|
||||
{t("admin.invites.ttlLegend")}
|
||||
</legend>
|
||||
<div
|
||||
className="mt-2 grid grid-cols-3 gap-2"
|
||||
role="radiogroup"
|
||||
>
|
||||
{TTL_CHOICES.map((days) => (
|
||||
<button
|
||||
key={days}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={ttlDays === days}
|
||||
onClick={() => setTtlDays(days)}
|
||||
data-testid={`admin-invites-ttl-${days}`}
|
||||
className={cn(
|
||||
"min-h-11 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
|
||||
ttlDays === days
|
||||
? "border-dracula-purple/50 bg-dracula-purple/10 text-dracula-purple"
|
||||
: "border-border text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{t("admin.invites.createDays", { days })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-invites-max-uses">
|
||||
{t("admin.invites.maxUsesLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-invites-max-uses"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={MAX_USES_CAP}
|
||||
step={1}
|
||||
value={maxUses}
|
||||
onChange={(e) => setMaxUses(e.target.value)}
|
||||
onBlur={() => {
|
||||
// Echo the submit-time clamp (1–50, integer) into
|
||||
// the field so the admin sees what will be minted.
|
||||
setMaxUses(String(clampMaxUses(maxUses)));
|
||||
}}
|
||||
className="w-24 tabular-nums"
|
||||
data-testid="admin-invites-max-uses"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.invites.maxUsesHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={create.isPending}
|
||||
aria-busy={create.isPending || undefined}
|
||||
data-testid="admin-invites-submit-create"
|
||||
>
|
||||
{create.isPending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
<div className="space-y-5">
|
||||
<fieldset>
|
||||
<legend className="text-sm font-medium">
|
||||
{t("admin.invites.ttlLegend")}
|
||||
</legend>
|
||||
<div
|
||||
className="mt-2 grid grid-cols-3 gap-2"
|
||||
role="radiogroup"
|
||||
>
|
||||
{TTL_CHOICES.map((days) => (
|
||||
<button
|
||||
key={days}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={ttlDays === days}
|
||||
onClick={() => setTtlDays(days)}
|
||||
data-testid={`admin-invites-ttl-${days}`}
|
||||
className={cn(
|
||||
"min-h-11 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
|
||||
ttlDays === days
|
||||
? "border-dracula-purple/50 bg-dracula-purple/10 text-dracula-purple"
|
||||
: "border-border text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{t("admin.invites.createDays", { days })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-invites-max-uses">
|
||||
{t("admin.invites.maxUsesLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-invites-max-uses"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={MAX_USES_CAP}
|
||||
step={1}
|
||||
value={maxUses}
|
||||
onChange={(e) => setMaxUses(e.target.value)}
|
||||
onBlur={() => {
|
||||
// Echo the submit-time clamp (1–50, integer) into
|
||||
// the field so the admin sees what will be minted.
|
||||
setMaxUses(String(clampMaxUses(maxUses)));
|
||||
}}
|
||||
className="w-24 tabular-nums"
|
||||
data-testid="admin-invites-max-uses"
|
||||
/>
|
||||
) : (
|
||||
<Ticket className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
{t("admin.invites.createConfirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.invites.maxUsesHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={create.isPending}
|
||||
aria-busy={create.isPending || undefined}
|
||||
data-testid="admin-invites-submit-create"
|
||||
>
|
||||
{create.isPending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Ticket className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
{t("admin.invites.createConfirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { toCSV } from "@/lib/export";
|
||||
@@ -131,7 +132,9 @@ export function LoginOverviewSection() {
|
||||
if (filter === "failed") {
|
||||
params.set("action", "auth.login.failed");
|
||||
}
|
||||
return apiGet<AuditLogResponse>(`/api/admin/audit-log?${params.toString()}`);
|
||||
return apiGet<AuditLogResponse>(
|
||||
`/api/admin/audit-log?${params.toString()}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -193,11 +196,12 @@ export function LoginOverviewSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollText className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">{t("admin.loginOverview")}</div>
|
||||
</div>
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={ScrollText}
|
||||
title={t("admin.loginOverview")}
|
||||
description={t("admin.loginOverviewDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{/* Quick-filter pills. Failed-only is the most common
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { type AdminAuditEntry, useAuthActionLabels } from "./_shared";
|
||||
@@ -38,7 +39,9 @@ export function RecentAuditPreview() {
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: queryKeys.adminAuditOverview(),
|
||||
queryFn: async () => {
|
||||
return apiGet<AuditLogResponse>(`/api/admin/audit-log?limit=${PREVIEW_LIMIT}&filter=auth`);
|
||||
return apiGet<AuditLogResponse>(
|
||||
`/api/admin/audit-log?limit=${PREVIEW_LIMIT}&filter=auth`,
|
||||
);
|
||||
},
|
||||
// Avoid hitting the DB on every page focus; the overview is the
|
||||
// type of page admins keep open and re-render frequently.
|
||||
@@ -52,26 +55,22 @@ export function RecentAuditPreview() {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="admin-overview-audit-heading"
|
||||
className="bg-card border-border rounded-xl border p-6"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollText className="text-muted-foreground h-5 w-5" aria-hidden="true" />
|
||||
<h2
|
||||
id="admin-overview-audit-heading"
|
||||
className="text-lg font-semibold"
|
||||
<SettingsCardHeader
|
||||
icon={ScrollText}
|
||||
titleId="admin-overview-audit-heading"
|
||||
title={t("admin.overview.auditTitle")}
|
||||
status={
|
||||
<Link
|
||||
href="/admin/login-overview"
|
||||
className="text-primary hover:text-primary/80 inline-flex items-center gap-1 text-sm"
|
||||
>
|
||||
{t("admin.overview.auditTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/login-overview"
|
||||
className="text-primary hover:text-primary/80 inline-flex items-center gap-1 text-sm"
|
||||
>
|
||||
{t("admin.overview.auditViewAll")}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
{t("admin.overview.auditViewAll")}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
{isLoading ? (
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
@@ -91,21 +92,17 @@ export function RemindersSection() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* v1.4.22 D / F-58 — icon swap so the section header matches
|
||||
the admin-shell nav tile (Bell). Clock now lives on the
|
||||
timing chips inline instead of competing as the page icon. */}
|
||||
<Bell className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">
|
||||
{t("admin.medicationReminders")}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 pl-7 text-xs">
|
||||
{t("admin.medicationRemindersDescription")}
|
||||
</p>
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
{/* v1.4.22 D / F-58 — icon swap so the section header matches
|
||||
the admin-shell nav tile (Bell). Clock now lives on the
|
||||
timing chips inline instead of competing as the page icon. */}
|
||||
<SettingsCardHeader
|
||||
icon={Bell}
|
||||
title={t("admin.medicationReminders")}
|
||||
description={t("admin.medicationRemindersDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="mt-4 space-y-3 pl-7">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-reminder-late" className="text-xs">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Bell, Globe, Key, MessageCircle, Smile } from "lucide-react";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
|
||||
@@ -10,15 +11,13 @@ export function ServicesSection() {
|
||||
const updateSettings = useUpdateSettings();
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">{t("admin.servicesGlobal")}</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 pl-7 text-xs">
|
||||
{t("admin.servicesGlobalDescription")}
|
||||
</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Globe}
|
||||
title={t("admin.servicesGlobal")}
|
||||
description={t("admin.servicesGlobalDescription")}
|
||||
/>
|
||||
<div className="mt-4 space-y-3 pl-7">
|
||||
<SettingsToggle
|
||||
label="Telegram"
|
||||
description={t("admin.telegramGlobal")}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
|
||||
|
||||
@@ -51,13 +52,14 @@ export function SystemStatusSection() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<HostMetricsChart />
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">{t("admin.systemStatus")}</div>
|
||||
</div>
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Server}
|
||||
title={t("admin.systemStatus")}
|
||||
description={t("admin.systemStatusDescription")}
|
||||
/>
|
||||
{status ? (
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="mt-4 grid gap-4 pl-7 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatusItem
|
||||
icon={Database}
|
||||
label={t("admin.database")}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
|
||||
|
||||
@@ -34,20 +35,16 @@ export function SystemStatusSummary() {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="admin-overview-snapshot-heading"
|
||||
className="bg-card border-border rounded-xl border p-6"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="text-muted-foreground h-5 w-5" aria-hidden="true" />
|
||||
<h2
|
||||
id="admin-overview-snapshot-heading"
|
||||
className="text-lg font-semibold"
|
||||
>
|
||||
{t("admin.overview.snapshotTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<SettingsCardHeader
|
||||
icon={Server}
|
||||
titleId="admin-overview-snapshot-heading"
|
||||
title={t("admin.overview.snapshotTitle")}
|
||||
/>
|
||||
|
||||
{status ? (
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StatusItem
|
||||
icon={Tag}
|
||||
label={t("admin.overview.snapshotVersion")}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Activity, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SettingsToggle,
|
||||
useAdminSettings,
|
||||
useUpdateSettings,
|
||||
ConfiguredBadge,
|
||||
} from "./_shared";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
@@ -71,25 +72,15 @@ export function UmamiSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.umamiTitle")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{configured && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("admin.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.umamiDescription")}
|
||||
</p>
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Activity}
|
||||
title={t("admin.umamiTitle")}
|
||||
description={t("admin.umamiDescription")}
|
||||
status={configured ? <ConfiguredBadge /> : null}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="mt-4 space-y-3 pl-7">
|
||||
<SettingsToggle
|
||||
label={t("admin.umamiEnabled")}
|
||||
icon={Activity}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { type AdminUser, PasswordInput } from "./_shared";
|
||||
@@ -220,18 +221,12 @@ export function UserManagementSection() {
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
{/* Header: stacks vertically on mobile so the filter pills get
|
||||
their own row instead of overlapping the title. The audit
|
||||
flagged this as CRITICAL #2 — at 393px the filter row was
|
||||
colliding with the count Badge and the user-count digit was
|
||||
floating into the title's gap. */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="text-muted-foreground h-5 w-5" />
|
||||
<div className="text-lg font-semibold">
|
||||
{t("admin.userManagement")}
|
||||
</div>
|
||||
{filteredUsers && (
|
||||
<SettingsCardHeader
|
||||
icon={Users}
|
||||
title={t("admin.userManagement")}
|
||||
description={t("admin.userManagementDescription")}
|
||||
titleAccessory={
|
||||
filteredUsers ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{filteredUsers.length}
|
||||
{filter !== "all" &&
|
||||
@@ -242,24 +237,25 @@ export function UserManagementSection() {
|
||||
</span>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Filters mirror the Settings-style horizontal pills */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{(["all", "admin", "user"] as const).map((value) => (
|
||||
<Button
|
||||
key={value}
|
||||
variant={filter === value ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="min-h-11 min-w-11 px-3 text-xs"
|
||||
onClick={() => setFilter(value)}
|
||||
aria-pressed={filter === value}
|
||||
>
|
||||
{t(`admin.section.users.filter.${value}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
status={
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{(["all", "admin", "user"] as const).map((value) => (
|
||||
<Button
|
||||
key={value}
|
||||
variant={filter === value ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="min-h-11 min-w-11 px-3 text-xs"
|
||||
onClick={() => setFilter(value)}
|
||||
aria-pressed={filter === value}
|
||||
>
|
||||
{t(`admin.section.users.filter.${value}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{filteredUsers ? (
|
||||
filteredUsers.length === 0 ? (
|
||||
|
||||
@@ -16,9 +16,16 @@
|
||||
* (`/admin`), which gates on `requireAdmin()`.
|
||||
*/
|
||||
|
||||
import { ArrowUpCircle, CheckCircle2, Clock, GitCommit, Tag } from "lucide-react";
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
GitCommit,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiGet } from "@/lib/api/api-fetch";
|
||||
@@ -77,42 +84,39 @@ export function VersionTileSection() {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="admin-overview-version-heading"
|
||||
className="bg-card border-border rounded-xl border p-6"
|
||||
className="bg-card border-border rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tag className="text-muted-foreground h-5 w-5" aria-hidden="true" />
|
||||
<h2
|
||||
id="admin-overview-version-heading"
|
||||
className="text-lg font-semibold"
|
||||
>
|
||||
{t("admin.overview.versionTileTitle")}
|
||||
</h2>
|
||||
<SettingsCardHeader
|
||||
icon={Tag}
|
||||
titleId="admin-overview-version-heading"
|
||||
title={t("admin.overview.versionTileTitle")}
|
||||
status={
|
||||
newer ? (
|
||||
<a
|
||||
href={
|
||||
(update.status === "newer_available" && update.html_url) ||
|
||||
"https://github.com/MBombeck/HealthLog/releases/latest"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bg-dracula-yellow/15 text-dracula-yellow border-dracula-yellow/30 inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-80"
|
||||
>
|
||||
<ArrowUpCircle className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("admin.overview.versionTileUpdateAvailable", {
|
||||
tag:
|
||||
update.status === "newer_available" ? update.latest_tag : "",
|
||||
})}
|
||||
</a>
|
||||
) : upToDate ? (
|
||||
<span className="text-dracula-green inline-flex items-center gap-1.5 text-xs font-medium">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("admin.overview.versionTileUpToDate")}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{newer ? (
|
||||
<a
|
||||
href={
|
||||
(update.status === "newer_available" && update.html_url) ||
|
||||
"https://github.com/MBombeck/HealthLog/releases/latest"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bg-dracula-yellow/15 text-dracula-yellow border-dracula-yellow/30 ml-auto inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-80"
|
||||
>
|
||||
<ArrowUpCircle className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("admin.overview.versionTileUpdateAvailable", {
|
||||
tag:
|
||||
update.status === "newer_available" ? update.latest_tag : "",
|
||||
})}
|
||||
</a>
|
||||
) : upToDate ? (
|
||||
<span className="text-dracula-green ml-auto inline-flex items-center gap-1.5 text-xs font-medium">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("admin.overview.versionTileUpToDate")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StatusItem
|
||||
icon={Tag}
|
||||
label={t("admin.overview.versionTileVersion")}
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { BellRing, Loader2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { PasswordInput, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
import {
|
||||
ConfiguredBadge,
|
||||
PasswordInput,
|
||||
useAdminSettings,
|
||||
useUpdateSettings,
|
||||
} from "./_shared";
|
||||
|
||||
export function WebPushVapidSection() {
|
||||
const { t } = useTranslations();
|
||||
@@ -48,27 +53,15 @@ export function WebPushVapidSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BellRing className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("admin.webPushVapidTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{configured && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("admin.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.webPushVapidDescription")}
|
||||
</p>
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={BellRing}
|
||||
title={t("admin.webPushVapidTitle")}
|
||||
description={t("admin.webPushVapidDescription")}
|
||||
status={configured ? <ConfiguredBadge /> : null}
|
||||
/>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2">
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="admin-web-push-public-key" className="text-xs">
|
||||
{t("admin.webPushVapidPublicKey")}
|
||||
|
||||
Reference in new issue
Block a user