mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Tidy admin settings cards
Offer every shipped locale as the server default language, move the app log refresh out of the header status slot, let filter selects fill their grid cells, name the coverage table as a focusable region, hide the legacy-key badge when only one key exists, keep the encryption cards still while they load, and stop repeating the page title in card titles.
This commit is contained in:
1 parent
37f922af97
commit
f4d2ec8988
13 files changed
+245
-74
No files matched your search
@@ -434,6 +434,41 @@ describe("PUT /api/admin/settings", () => {
|
||||
expect(args?.update).toEqual({ defaultUserTimezone: null });
|
||||
});
|
||||
|
||||
// Every shipped locale is a valid server default, not only de/en.
|
||||
it("persists any shipped locale as the default language", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
defaultLocale: "fr",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
defaultUserTimezone: null,
|
||||
} as never);
|
||||
const res = await PUT(jsonReq({ defaultLocale: "fr" }));
|
||||
expect(res.status).toBe(200);
|
||||
const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(args?.update).toEqual({ defaultLocale: "fr" });
|
||||
});
|
||||
|
||||
it("returns 422 for a locale the app does not ship", async () => {
|
||||
const res = await PUT(jsonReq({ defaultLocale: "xx" }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 for an invalid IANA zone in defaultUserTimezone", async () => {
|
||||
const res = await PUT(jsonReq({ defaultUserTimezone: "Mars/Olympus" }));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
// "Safe to drop the legacy key" needs a legacy key to drop. With a single
|
||||
// configured key the server's `safeToDropRetiredKeys` is vacuously true, and
|
||||
// the coverage card used to name a key that does not exist.
|
||||
|
||||
const status = {
|
||||
current: {} as Record<string, unknown>,
|
||||
};
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: status.current,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("../key-backup-card", () => ({ KeyBackupCard: () => null }));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { EncryptionSection } from "../encryption-section";
|
||||
|
||||
function statusWith(configuredKeyCount: number) {
|
||||
return {
|
||||
activeKeyId: "v2",
|
||||
configuredKeyCount,
|
||||
rotationComplete: true,
|
||||
totalRows: 10,
|
||||
activeRows: 10,
|
||||
staleRows: 0,
|
||||
columns: [],
|
||||
rotation: {
|
||||
state: "idle",
|
||||
lastRequestedAt: null,
|
||||
lastCompletedAt: null,
|
||||
lastResult: null,
|
||||
},
|
||||
backups: {
|
||||
stored: [],
|
||||
unrecorded: { copies: 0, oldestAt: null },
|
||||
offhost: [],
|
||||
offhostExpirationDays: null,
|
||||
retiredKeysStillNeeded: [],
|
||||
},
|
||||
safeToDropRetiredKeys: true,
|
||||
};
|
||||
}
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<EncryptionSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("encryption coverage — the legacy-key badge", () => {
|
||||
beforeEach(() => {
|
||||
status.current = {};
|
||||
});
|
||||
|
||||
it("offers the drop only when a retired key is configured", () => {
|
||||
status.current = statusWith(2);
|
||||
expect(render()).toContain("Safe to drop the legacy key");
|
||||
});
|
||||
|
||||
it("says nothing about a legacy key when only one key exists", () => {
|
||||
status.current = statusWith(1);
|
||||
const html = render();
|
||||
expect(html).not.toContain("Safe to drop the legacy key");
|
||||
expect(html).not.toContain('data-slot="encryption-backups-need-keys"');
|
||||
});
|
||||
|
||||
it("names the per-column table as a focusable scroll region", () => {
|
||||
status.current = statusWith(1);
|
||||
expect(render()).toMatch(
|
||||
/role="region"[^>]*tabindex="0"|tabindex="0"[^>]*role="region"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -97,7 +97,7 @@ describe("admin sections — SSR smoke", () => {
|
||||
|
||||
it("<RemindersSection> renders", () => {
|
||||
const html = render(<RemindersSection />);
|
||||
expect(html).toContain("Medication Reminders");
|
||||
expect(html).toContain("Medication reminders");
|
||||
});
|
||||
|
||||
it("<UserManagementSection> renders with filter pills", () => {
|
||||
@@ -115,7 +115,7 @@ describe("admin sections — SSR smoke", () => {
|
||||
|
||||
it("<LoginOverviewSection> renders", () => {
|
||||
const html = render(<LoginOverviewSection />);
|
||||
expect(html).toContain("Login Overview");
|
||||
expect(html).toContain("Sign-in events");
|
||||
});
|
||||
|
||||
it("<BackupsSection> renders heading + run-now button", () => {
|
||||
|
||||
@@ -119,27 +119,16 @@ export function AppLogPreviewSection() {
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={FileText}
|
||||
title={t("admin.section.app-logs.title")}
|
||||
title={t("admin.section.app-logs.cardTitle")}
|
||||
description={t("admin.section.app-logs.processNote")}
|
||||
status={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
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>
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="text-sm">{t("admin.section.app-logs.processDetail")}</p>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Filters and the refresh share one toolbar row above the table; the
|
||||
header status slot carries status, not actions (design standards
|
||||
§12). */}
|
||||
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-[repeat(4,minmax(0,1fr))_auto]">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.section.app-logs.filterTraceId")}
|
||||
@@ -158,7 +147,10 @@ export function AppLogPreviewSection() {
|
||||
value={level}
|
||||
onValueChange={(v) => setLevel(v as LogLevel | "__all__")}
|
||||
>
|
||||
<SelectTrigger aria-label={t("admin.section.app-logs.filterLevel")}>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
aria-label={t("admin.section.app-logs.filterLevel")}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t("admin.section.app-logs.filterLevel")}
|
||||
/>
|
||||
@@ -174,7 +166,10 @@ export function AppLogPreviewSection() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={range} onValueChange={(v) => setRange(v as RangePreset)}>
|
||||
<SelectTrigger aria-label={t("admin.section.app-logs.filterRange")}>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
aria-label={t("admin.section.app-logs.filterRange")}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -192,6 +187,18 @@ export function AppLogPreviewSection() {
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 min-w-11 justify-self-end sm:min-h-9 sm:min-w-9"
|
||||
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>
|
||||
|
||||
{isLoading ? (
|
||||
|
||||
@@ -43,6 +43,7 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { getApiErrorMessage } from "./_shared";
|
||||
import { KeyBackupCard } from "./key-backup-card";
|
||||
import { useKeyBackupStatus } from "./use-key-backup-status";
|
||||
|
||||
interface ColumnScan {
|
||||
model: string;
|
||||
@@ -88,6 +89,9 @@ export function EncryptionSection() {
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Same query as the card's own (deduplicated by key): read here only to
|
||||
// know when the page can paint the cards below it in one step.
|
||||
const keyBackup = useKeyBackupStatus();
|
||||
const statusQuery = useQuery({
|
||||
queryKey: queryKeys.adminEncryptionStatus(),
|
||||
queryFn: async (): Promise<EncryptionStatus> => {
|
||||
@@ -129,7 +133,19 @@ export function EncryptionSection() {
|
||||
// downward the moment the status resolved. Same shape as
|
||||
// `coach-feedback-section.tsx`, which fixed this for itself and never had
|
||||
// the fix carried across.
|
||||
if (statusQuery.isLoading || statusQuery.isError || !statusQuery.data) {
|
||||
// Until both reads land, only the key-backup card paints. It is the first
|
||||
// card and grows into its loaded shape with nothing under it yet; painting
|
||||
// the coverage card below it while the backup card was still a one-line
|
||||
// loader moved that card down the page when the backup status arrived.
|
||||
if (statusQuery.isLoading || keyBackup.isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<KeyBackupCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (statusQuery.isError || !statusQuery.data) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<KeyBackupCard />
|
||||
@@ -143,16 +159,9 @@ export function EncryptionSection() {
|
||||
{t("admin.section.encryption.coverageDetail")}
|
||||
</p>
|
||||
|
||||
{statusQuery.isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
{t("admin.section.encryption.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t("admin.section.encryption.loadError")}
|
||||
</p>
|
||||
)}
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t("admin.section.encryption.loadError")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
);
|
||||
@@ -160,6 +169,7 @@ export function EncryptionSection() {
|
||||
|
||||
const s = statusQuery.data;
|
||||
const running = s.rotation.state === "running" || rotate.isPending;
|
||||
const hasRetiredKey = s.configuredKeyCount > 1;
|
||||
// Only the rows that share a coverage view need the per-column table; sort
|
||||
// stale-first so an operator sees what still needs rotating.
|
||||
const columns = [...s.columns].sort((a, b) => b.legacy - a.legacy);
|
||||
@@ -174,6 +184,9 @@ export function EncryptionSection() {
|
||||
title={t("admin.section.encryption.coverageTitle")}
|
||||
description={t("admin.section.encryption.coverageDescription")}
|
||||
/>
|
||||
<p className="text-sm">
|
||||
{t("admin.section.encryption.coverageDetail")}
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Stat
|
||||
label={t("admin.section.encryption.activeKeyId")}
|
||||
@@ -192,20 +205,26 @@ export function EncryptionSection() {
|
||||
value={s.staleRows.toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
{s.safeToDropRetiredKeys ? (
|
||||
{/* "Safe to drop the legacy key" needs a legacy key to drop. With a
|
||||
single configured key the server's flag is vacuously true, and
|
||||
the badge named a key that does not exist. */}
|
||||
{hasRetiredKey && s.safeToDropRetiredKeys ? (
|
||||
<Badge className="border-success/40 bg-success/15 text-success">
|
||||
{t("admin.section.encryption.safeToDropLegacy")}
|
||||
</Badge>
|
||||
) : s.rotationComplete ? (
|
||||
// Every row is on the active key, and that is not the whole
|
||||
// answer: a backup keeps the key its content was written under.
|
||||
<Badge variant="secondary" data-slot="encryption-backups-need-keys">
|
||||
{s.backups.retiredKeysStillNeeded.length > 0
|
||||
? t("admin.section.encryption.backupsStillNeed", {
|
||||
keys: s.backups.retiredKeysStillNeeded.join(", "),
|
||||
})
|
||||
: t("admin.section.encryption.backupsUnrecordedBadge")}
|
||||
</Badge>
|
||||
!hasRetiredKey &&
|
||||
s.backups.retiredKeysStillNeeded.length === 0 ? null : (
|
||||
<Badge variant="secondary" data-slot="encryption-backups-need-keys">
|
||||
{s.backups.retiredKeysStillNeeded.length > 0
|
||||
? t("admin.section.encryption.backupsStillNeed", {
|
||||
keys: s.backups.retiredKeysStillNeeded.join(", "),
|
||||
})
|
||||
: t("admin.section.encryption.backupsUnrecordedBadge")}
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
{t("admin.section.encryption.rotationIncomplete", {
|
||||
@@ -341,7 +360,15 @@ export function EncryptionSection() {
|
||||
title={t("admin.section.encryption.columnsTitle")}
|
||||
description={t("admin.section.encryption.columnsDescription")}
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
{/* Focusable, named scroll region: on a phone the table scrolls
|
||||
sideways, and a keyboard user has to be able to reach it
|
||||
(axe scrollable-region-focusable). */}
|
||||
<div
|
||||
className="overflow-x-auto"
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label={t("admin.section.encryption.columnsTitle")}
|
||||
>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-left">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { localeLabels, locales } from "@/lib/i18n/config";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Settings } from "lucide-react";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
@@ -142,8 +143,13 @@ export function GeneralSettingsSection() {
|
||||
disabled={updateSettings.isPending}
|
||||
className="self-end sm:w-auto sm:self-auto"
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
{/* Every shipped locale, not a hand-kept pair: a new language
|
||||
that ships without being offered here was the drift. */}
|
||||
{locales.map((loc) => (
|
||||
<option key={loc} value={loc}>
|
||||
{localeLabels[loc]}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -157,7 +157,6 @@ export function GlitchtipSection() {
|
||||
<div className="space-y-3">
|
||||
<SettingsToggle
|
||||
label={t("admin.glitchtipEnabled")}
|
||||
icon={AlertTriangle}
|
||||
checked={settings?.glitchtipEnabled ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSettings.mutate({ glitchtipEnabled: checked })
|
||||
@@ -188,7 +187,7 @@ export function GlitchtipSection() {
|
||||
) : null}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="admin-glitchtip-dsn" className="text-xs">
|
||||
<Label htmlFor="admin-glitchtip-dsn">
|
||||
{t("admin.glitchtipDsn")}
|
||||
</Label>
|
||||
<Input
|
||||
@@ -206,7 +205,7 @@ export function GlitchtipSection() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-glitchtip-environment" className="text-xs">
|
||||
<Label htmlFor="admin-glitchtip-environment">
|
||||
{t("admin.glitchtipEnvironment")}
|
||||
</Label>
|
||||
<Input
|
||||
|
||||
@@ -179,7 +179,7 @@ export function LoginOverviewSection() {
|
||||
if (entries.length === 0) return;
|
||||
const labels = {
|
||||
timestamp: t("admin.timestamp"),
|
||||
user: t("admin.users"),
|
||||
user: t("admin.userColumn"),
|
||||
ip: t("admin.ip"),
|
||||
location: t("admin.location"),
|
||||
// v1.4.27 B3 — `admin.carrier` is shipped by bucket B6. The
|
||||
@@ -223,7 +223,7 @@ export function LoginOverviewSection() {
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={ScrollText}
|
||||
title={t("admin.loginOverview")}
|
||||
title={t("admin.loginOverviewCardTitle")}
|
||||
description={t("admin.loginOverviewDescription")}
|
||||
/>
|
||||
|
||||
@@ -233,6 +233,7 @@ export function LoginOverviewSection() {
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={filter === "all" ? "default" : "ghost"}
|
||||
aria-pressed={filter === "all"}
|
||||
size="sm"
|
||||
className="min-h-11 min-w-11 px-3 text-xs"
|
||||
onClick={resetPageOnFilterChange(() => setFilter("all"))}
|
||||
@@ -241,6 +242,7 @@ export function LoginOverviewSection() {
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === "failed" ? "default" : "ghost"}
|
||||
aria-pressed={filter === "failed"}
|
||||
size="sm"
|
||||
className="min-h-11 min-w-11 px-3 text-xs"
|
||||
onClick={resetPageOnFilterChange(() => setFilter("failed"))}
|
||||
@@ -250,7 +252,7 @@ export function LoginOverviewSection() {
|
||||
</div>
|
||||
|
||||
{/* Detailed filter row */}
|
||||
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-[minmax(0,1.5fr)_repeat(3,minmax(0,1fr))]">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.section.auditLog.filterActor")}
|
||||
@@ -269,6 +271,7 @@ export function LoginOverviewSection() {
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
aria-label={t("admin.section.auditLog.filterAction")}
|
||||
>
|
||||
<SelectValue
|
||||
@@ -305,7 +308,10 @@ export function LoginOverviewSection() {
|
||||
setRange(v as DateRangePreset);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label={t("admin.section.auditLog.filterDate")}>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
aria-label={t("admin.section.auditLog.filterDate")}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -413,7 +419,7 @@ export function LoginOverviewSection() {
|
||||
{t("admin.status")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.users")}
|
||||
{t("admin.userColumn")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.action")}
|
||||
|
||||
@@ -95,11 +95,7 @@ export function ModuleAvailabilitySection() {
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={ToggleLeft}
|
||||
title={t("admin.modules.title")}
|
||||
description={t("admin.modules.description")}
|
||||
/>
|
||||
<SettingsCardHeader icon={ToggleLeft} title={t("admin.modules.title")} />
|
||||
<p className="text-sm">{t("admin.modules.detail")}</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
@@ -133,7 +129,7 @@ export function ModuleAvailabilitySection() {
|
||||
href="/admin/coach"
|
||||
className="text-primary text-xs underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("admin.assistant.title")}
|
||||
{t("admin.section.coach.title")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -215,16 +215,14 @@ export function RemindersSection() {
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-reminder-late" className="text-xs">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="admin-reminder-late">
|
||||
{t("admin.reminderLateMinutes")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.reminderLateMinutesDescription")}
|
||||
</p>
|
||||
<Input
|
||||
id="admin-reminder-late"
|
||||
aria-describedby="admin-reminder-late-hint"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
enterKeyHint="next"
|
||||
@@ -240,16 +238,20 @@ export function RemindersSection() {
|
||||
disabled={updateSettings.isPending}
|
||||
className="w-32"
|
||||
/>
|
||||
<p
|
||||
id="admin-reminder-late-hint"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("admin.reminderLateMinutesDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-reminder-missed" className="text-xs">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="admin-reminder-missed">
|
||||
{t("admin.reminderMissedMinutes")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.reminderMissedMinutesDescription")}
|
||||
</p>
|
||||
<Input
|
||||
id="admin-reminder-missed"
|
||||
aria-describedby="admin-reminder-missed-hint"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
enterKeyHint="done"
|
||||
@@ -265,6 +267,12 @@ export function RemindersSection() {
|
||||
disabled={updateSettings.isPending}
|
||||
className="w-32"
|
||||
/>
|
||||
<p
|
||||
id="admin-reminder-missed-hint"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("admin.reminderMissedMinutesDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,7 +85,6 @@ export function UmamiSection() {
|
||||
<div className="space-y-3">
|
||||
<SettingsToggle
|
||||
label={t("admin.umamiEnabled")}
|
||||
icon={Activity}
|
||||
checked={settings?.umamiEnabled ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSettings.mutate({ umamiEnabled: checked })
|
||||
@@ -94,7 +93,7 @@ export function UmamiSection() {
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-umami-script-url" className="text-xs">
|
||||
<Label htmlFor="admin-umami-script-url">
|
||||
{t("admin.umamiScriptUrl")}
|
||||
</Label>
|
||||
<Input
|
||||
@@ -112,7 +111,7 @@ export function UmamiSection() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-umami-website-id" className="text-xs">
|
||||
<Label htmlFor="admin-umami-website-id">
|
||||
{t("admin.umamiWebsiteId")}
|
||||
</Label>
|
||||
<Input
|
||||
|
||||
@@ -131,7 +131,7 @@ export function WebPushVapidSection() {
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="admin-web-push-public-key" className="text-xs">
|
||||
<Label htmlFor="admin-web-push-public-key">
|
||||
{t("admin.webPushVapidPublicKey")}
|
||||
</Label>
|
||||
<Input
|
||||
@@ -151,7 +151,7 @@ export function WebPushVapidSection() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="admin-web-push-private-key" className="text-xs">
|
||||
<Label htmlFor="admin-web-push-private-key">
|
||||
{t("admin.webPushVapidPrivateKey")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
@@ -171,7 +171,7 @@ export function WebPushVapidSection() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="admin-web-push-subject" className="text-xs">
|
||||
<Label htmlFor="admin-web-push-subject">
|
||||
{t("admin.webPushVapidSubject")}
|
||||
</Label>
|
||||
<Input
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod/v4";
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
|
||||
/**
|
||||
@@ -10,7 +11,9 @@ export const adminSettingsSchema = z
|
||||
registrationEnabled: z.boolean().optional(),
|
||||
// v1.23 — instance-wide "require a second factor" policy.
|
||||
mfaRequired: z.boolean().optional(),
|
||||
defaultLocale: z.enum(["de", "en"]).optional(),
|
||||
// Every shipped locale. It was the pair de/en long after the app shipped
|
||||
// seven, so the admin picker could offer only two of them.
|
||||
defaultLocale: z.enum(locales).optional(),
|
||||
telegramGlobal: z.boolean().optional(),
|
||||
ntfyGlobal: z.boolean().optional(),
|
||||
webPushGlobal: z.boolean().optional(),
|
||||
|
||||
Reference in new issue
Block a user