Set how long rotated log files are kept, on the Hardening page #33
No files matched your search
@@ -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() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LogRetentionCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<KeepForDays | undefined>(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 (
|
||||
<section className="space-y-3 rounded-xl border p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<FileClock className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{t('logRetention.title', 'Log files')}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{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.',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{canUpdate && !editing && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setValue(String(keep ?? 30));
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
{t('logRetention.change', 'Change…')}
|
||||
</Button>
|
||||
{keep !== null && (
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void save(null)}>
|
||||
{t('logRetention.keepAll', 'Keep every file')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canUpdate && editing && (
|
||||
<form
|
||||
className="flex flex-wrap items-end gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (validDays(value)) void save(Number(value));
|
||||
}}
|
||||
>
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="text-muted-foreground">{t('logRetention.daysLabel', 'Delete after (days)')}</span>
|
||||
<Input type="number" min={1} value={value} onChange={(e) => setValue(e.target.value)} className="w-32" />
|
||||
</label>
|
||||
<Button type="submit" size="sm" disabled={busy || !validDays(value)}>
|
||||
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('logRetention.save', 'Save')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" disabled={busy} onClick={() => setEditing(false)}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>): KeepForDays {
|
||||
return typeof raw.keepForDays === 'number' ? raw.keepForDays : null;
|
||||
}
|
||||
|
||||
export async function fetchLogSettings(signal?: AbortSignal): Promise<KeepForDays> {
|
||||
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<string, unknown>[] }).list?.[0];
|
||||
if (!item) throw new PolicyUnavailable('notFound');
|
||||
return parseKeepForDays(item);
|
||||
}
|
||||
|
||||
export async function updateLogSettings(keepForDays: KeepForDays): Promise<void> {
|
||||
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<string, JmapSetError> | 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;
|
||||
}
|
||||
Reference in new issue
Block a user