feat: add the settings page with the audit trail
Shop identity, the cantonal code that drives the holiday calendar, the two wake intervals and the image format, plus the device list and a translation self-test. The audit trail sits at the bottom, rendered field by field in French rather than as raw JSON. Regenerating a device token shows it once and stores only its digest, so the panel has to be re-paired through the captive portal afterwards. That is the point rather than a drawback: this is the control you reach for when a token may have leaked, and a version that let you read the old one back would not be one. The server URL to type into the captive portal is reconstructed from the incoming request, so the value shown is the one the device would actually have to reach — not one assembled from configuration that may not match what the proxy is serving. Wake intervals are bounded by the same constants the device API enforces, so a value the settings page accepts cannot be one the panel is refused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { auth } from '@/lib/auth';
|
||||
import { publicBaseUrl } from '@/lib/config';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { loadSettings } from '@/lib/schedule/context';
|
||||
|
||||
import SettingsForm, { type DeviceRow } from './SettingsForm';
|
||||
|
||||
export const metadata = { title: 'Paramètres — ITA ITO' };
|
||||
|
||||
/** The audit trail is a log, not a database browser: the last fifty suffice. */
|
||||
const AUDIT_LIMIT = 50;
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const [session, settings, headerList] = await Promise.all([auth(), loadSettings(), headers()]);
|
||||
|
||||
const [devices, audit] = await Promise.all([
|
||||
prisma.device.findMany({ orderBy: { createdAt: 'asc' } }),
|
||||
prisma.auditLog.findMany({ orderBy: { createdAt: 'desc' }, take: AUDIT_LIMIT }),
|
||||
]);
|
||||
|
||||
// Reconstructed from the incoming request so the value shown is the one the
|
||||
// device would actually have to reach.
|
||||
const baseUrl = publicBaseUrl(
|
||||
new Request('https://placeholder.invalid', { headers: headerList }),
|
||||
);
|
||||
|
||||
const deviceRows: DeviceRow[] = devices.map((device) => ({
|
||||
id: device.id,
|
||||
friendlyId: device.friendlyId,
|
||||
macAddress: device.macAddress,
|
||||
lastSeenAt: device.lastSeenAt ? device.lastSeenAt.toLocaleString('fr-CH') : null,
|
||||
fwVersion: device.fwVersion,
|
||||
}));
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-10">
|
||||
<h1 className="text-2xl">Paramètres</h1>
|
||||
|
||||
<SettingsForm
|
||||
canEdit={session?.user?.role === 'admin'}
|
||||
initial={{
|
||||
shopName: settings.shopName,
|
||||
timezone: settings.timezone,
|
||||
countryIsoCode: settings.countryIsoCode,
|
||||
subdivisionCode: settings.subdivisionCode,
|
||||
refreshRateOpenSec: settings.refreshRateOpenSec,
|
||||
refreshRateClosedSec: settings.refreshRateClosedSec,
|
||||
imageFormat: settings.imageFormat,
|
||||
}}
|
||||
devices={deviceRows}
|
||||
deviceBaseUrl={baseUrl}
|
||||
/>
|
||||
|
||||
<section className="mt-12">
|
||||
<h2 className="text-lg">Journal d’audit</h2>
|
||||
<p className="mt-1 text-sm text-[var(--ink-muted)]">
|
||||
Les {AUDIT_LIMIT} dernières actions. Qui a changé quoi, et quand.
|
||||
</p>
|
||||
|
||||
{audit.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-[var(--ink-muted)]">Aucune action enregistrée.</p>
|
||||
) : (
|
||||
<ul className="mt-3 divide-y divide-[var(--line)] overflow-hidden rounded-[var(--radius-md)] border border-[var(--line)] bg-[var(--surface)] text-sm">
|
||||
{audit.map((entry) => (
|
||||
<li key={entry.id} className="px-4 py-2.5">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="text-[var(--ink-muted)] tabular-nums">
|
||||
{entry.createdAt.toLocaleString('fr-CH')}
|
||||
</span>
|
||||
<span className="font-mono text-xs">{entry.action}</span>
|
||||
<span className="text-[var(--ink-muted)]">{entry.userEmail}</span>
|
||||
</div>
|
||||
{entry.diff ? (
|
||||
<ul className="mt-1 space-y-0.5 text-[var(--ink-muted)]">
|
||||
{Object.entries(entry.diff as Record<string, { before: unknown; after: unknown }>).map(
|
||||
([field, change]) => (
|
||||
<li key={field}>
|
||||
<span className="font-medium">{field}</span> :{' '}
|
||||
{render(change.before)} → {render(change.after)}
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function render(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '—';
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'oui' : 'non';
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
Reference in New Issue
Block a user