From afe5209bca8807bee8587fde1676ac1060957842 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 08:27:43 -0700 Subject: [PATCH] Set how long rotated log files are kept, on the Hardening page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Personal-data catalog spec, D1: a "Log files" card on Settings › Security › Hardening reads and sets inbuxa:LogSettings.keepForDays. It says plainly what the files hold and whether they're kept with no limit or deleted after N days on every server; Change… takes whole days, at least one, and "Keep every file" goes back to no limit. Visible with sysTracerGet, editable with sysTracerUpdate; on a server without the setting the card isn't shown. Tested: unit tests for reading the setting and the day rule; typecheck, lint; in headless Chrome against a local server on inbuxa-server feature/log-retention: no limit, then 21 days, then back. --- .../hardening/LegacyProtocolsPage.tsx | 3 + src/features/hardening/LogRetentionCard.tsx | 122 ++++++++++++++++++ src/features/hardening/logSettings.test.ts | 25 ++++ src/features/hardening/logSettings.ts | 58 +++++++++ 4 files changed, 208 insertions(+) create mode 100644 src/features/hardening/LogRetentionCard.tsx create mode 100644 src/features/hardening/logSettings.test.ts create mode 100644 src/features/hardening/logSettings.ts diff --git a/src/features/hardening/LegacyProtocolsPage.tsx b/src/features/hardening/LegacyProtocolsPage.tsx index 28fb015..e0ddc68 100644 --- a/src/features/hardening/LegacyProtocolsPage.tsx +++ b/src/features/hardening/LegacyProtocolsPage.tsx @@ -40,6 +40,7 @@ import { type SwitchUpdate, } from './protocolPolicy'; import { ConfirmTurnOff, ImpactPanel, ProtocolSwitch, Statement } from './parts'; +import { LogRetentionCard } from './LogRetentionCard'; type Load = { kind: 'loading' } | { kind: 'ready'; policy: ProtocolPolicy } | { kind: 'error'; message: string }; @@ -195,6 +196,8 @@ export function LegacyProtocolsPage() { }} /> )} + + ); } diff --git a/src/features/hardening/LogRetentionCard.tsx b/src/features/hardening/LogRetentionCard.tsx new file mode 100644 index 0000000..858e6a9 --- /dev/null +++ b/src/features/hardening/LogRetentionCard.tsx @@ -0,0 +1,122 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: how long rotated log files are kept (personal-data catalog spec, + * D1), on Settings › Security › Hardening. Log files carry client IP + * addresses and email addresses; without a limit they are never deleted. + */ + +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FileClock, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { useAccountStore } from '@/stores/accountStore'; +import { toast } from '@/hooks/use-toast'; +import { fetchLogSettings, updateLogSettings, validDays, type KeepForDays } from './logSettings'; + +export function LogRetentionCard() { + const { t } = useTranslation(); + const canGet = useAccountStore((s) => s.hasPermission('sysTracerGet')); + const canUpdate = useAccountStore((s) => s.hasPermission('sysTracerUpdate')); + const [keep, setKeep] = useState(undefined); + const [editing, setEditing] = useState(false); + const [value, setValue] = useState('30'); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!canGet) return; + const controller = new AbortController(); + fetchLogSettings(controller.signal) + .then((days) => !controller.signal.aborted && setKeep(days)) + // An older server has no such setting: show nothing + .catch(() => {}); + return () => controller.abort(); + }, [canGet]); + + if (keep === undefined) return null; + + const save = async (days: KeepForDays) => { + setBusy(true); + try { + await updateLogSettings(days); + setKeep(days); + setEditing(false); + } catch (e) { + toast({ + variant: 'destructive', + title: t('logRetention.failed', 'Not saved'), + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ +
+

{t('logRetention.title', 'Log files')}

+

+ {keep === null + ? t( + 'logRetention.forever', + 'Rotated log files are kept with no limit. They record client IP addresses and email addresses.', + ) + : t('logRetention.days', { + count: keep, + defaultValue_one: 'Rotated log files are deleted after 1 day, on every server.', + defaultValue_other: 'Rotated log files are deleted after {{count}} days, on every server.', + })} +

+
+
+ {canUpdate && !editing && ( +
+ + {keep !== null && ( + + )} +
+ )} + {canUpdate && editing && ( +
{ + e.preventDefault(); + if (validDays(value)) void save(Number(value)); + }} + > + + + +
+ )} +
+ ); +} diff --git a/src/features/hardening/logSettings.test.ts b/src/features/hardening/logSettings.test.ts new file mode 100644 index 0000000..be95111 --- /dev/null +++ b/src/features/hardening/logSettings.test.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, it } from 'vitest'; +import { parseKeepForDays, validDays } from './logSettings'; + +describe('log settings', () => { + it('reads null as keep every file', () => { + expect(parseKeepForDays({ keepForDays: null })).toBeNull(); + expect(parseKeepForDays({})).toBeNull(); + expect(parseKeepForDays({ keepForDays: 30 })).toBe(30); + }); + + it('takes whole days, at least one', () => { + expect(validDays('30')).toBe(true); + expect(validDays('1')).toBe(true); + expect(validDays('0')).toBe(false); + expect(validDays('1.5')).toBe(false); + expect(validDays('')).toBe(false); + expect(validDays('-3')).toBe(false); + }); +}); diff --git a/src/features/hardening/logSettings.ts b/src/features/hardening/logSettings.ts new file mode 100644 index 0000000..902e202 --- /dev/null +++ b/src/features/hardening/logSettings.ts @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: `inbuxa:LogSettings`, how long rotated log files are kept + * (personal-data catalog spec, D1). `keepForDays` null keeps every file. + */ + +import { getAccountId, jmapRequest } from '@/services/jmap/client'; +import type { JmapSetError } from '@/types/jmap'; +import { INBUXA_CAPABILITY, PolicyUnavailable } from './protocolPolicy'; + +const OBJECT = 'inbuxa:LogSettings'; + +/** Days kept, or null for every file. */ +export type KeepForDays = number | null; + +export function parseKeepForDays(raw: Record): KeepForDays { + return typeof raw.keepForDays === 'number' ? raw.keepForDays : null; +} + +export async function fetchLogSettings(signal?: AbortSignal): Promise { + const accountId = getAccountId('x:Tracer'); + const responses = await jmapRequest([[`${OBJECT}/get`, { accountId, ids: null }, '0']], signal, [INBUXA_CAPABILITY]); + const [name, result] = responses[0] ?? []; + if (name !== `${OBJECT}/get`) { + const type = (result as { type?: string } | undefined)?.type; + if (type === 'unknownMethod' || type === 'unknownCapability') throw new PolicyUnavailable(type); + throw new Error((result as { description?: string } | undefined)?.description ?? type ?? 'Request failed'); + } + const item = (result as { list?: Record[] }).list?.[0]; + if (!item) throw new PolicyUnavailable('notFound'); + return parseKeepForDays(item); +} + +export async function updateLogSettings(keepForDays: KeepForDays): Promise { + const accountId = getAccountId('x:Tracer'); + const responses = await jmapRequest( + [[`${OBJECT}/set`, { accountId, update: { singleton: { keepForDays } } }, '0']], + undefined, + [INBUXA_CAPABILITY], + ); + const [name, result] = responses[0] ?? []; + if (name !== `${OBJECT}/set`) { + throw new Error((result as { description?: string } | undefined)?.description ?? 'Request failed'); + } + const failed = (result as { notUpdated?: Record | null }).notUpdated?.singleton; + if (failed) throw new Error(failed.description ?? failed.type); +} + +/** Whether a typed value is a whole number of days, at least one. */ +export function validDays(value: string): boolean { + const n = Number(value); + return value.trim() !== '' && Number.isInteger(n) && n >= 1; +} -- 2.54.0