From 1668240724607d29a2f442e0dd0a3c26be72059d Mon Sep 17 00:00:00 2001 From: vl Date: Sun, 20 Sep 2026 21:26:05 +0200 Subject: [PATCH] feat: add the settings page with the audit trail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd --- app/admin/layout.tsx | 1 + app/admin/parametres/SettingsForm.tsx | 282 ++++++++++++++++++++++++++ app/admin/parametres/actions.ts | 169 +++++++++++++++ app/admin/parametres/page.tsx | 104 ++++++++++ 4 files changed, 556 insertions(+) create mode 100644 app/admin/parametres/SettingsForm.tsx create mode 100644 app/admin/parametres/actions.ts create mode 100644 app/admin/parametres/page.tsx diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 6910ef6..313d636 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -11,6 +11,7 @@ const NAV = [ { href: '/admin/vacances', label: 'Vacances' }, { href: '/admin/messages', label: 'Messages' }, { href: '/admin/feries', label: 'Fériés' }, + { href: '/admin/parametres', label: 'Paramètres' }, ]; /** diff --git a/app/admin/parametres/SettingsForm.tsx b/app/admin/parametres/SettingsForm.tsx new file mode 100644 index 0000000..6759861 --- /dev/null +++ b/app/admin/parametres/SettingsForm.tsx @@ -0,0 +1,282 @@ +'use client'; + +import { useState, useTransition } from 'react'; + +import { + forgetDevice, + regenerateDeviceToken, + saveSettings, + testTranslation, + type SettingsInput, +} from './actions'; + +export type DeviceRow = { + id: string; + friendlyId: string; + macAddress: string; + lastSeenAt: string | null; + fwVersion: string | null; +}; + +export default function SettingsForm({ + canEdit, + initial, + devices, + deviceBaseUrl, +}: { + canEdit: boolean; + initial: SettingsInput; + devices: DeviceRow[]; + deviceBaseUrl: string; +}) { + const [form, setForm] = useState(initial); + const [pending, startTransition] = useTransition(); + const [message, setMessage] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null); + const [issuedToken, setIssuedToken] = useState<{ id: string; token: string } | null>(null); + + function set(key: K, value: SettingsInput[K]) { + setMessage(null); + setForm((current) => ({ ...current, [key]: value })); + } + + function run(work: () => Promise<{ ok: true } | { ok: false; error: string }>, success: string) { + setMessage(null); + startTransition(async () => { + const result = await work(); + setMessage(result.ok ? { kind: 'ok', text: success } : { kind: 'error', text: result.error }); + }); + } + + return ( +
+ {message ? ( +

+ {message.text} +

+ ) : null} + +
+

Boutique

+ +
+ + set('shopName', event.target.value)} + className={inputClass} + /> + + + set('timezone', event.target.value)} + className={inputClass} + /> + + + set('countryIsoCode', event.target.value)} + className={inputClass} + /> + + + set('subdivisionCode', event.target.value)} + className={inputClass} + /> + +
+
+ +
+

Écran

+

+ L’écran dort entre deux réveils. Un intervalle court garde l’affichage juste, un intervalle + long ménage la batterie. +

+ +
+ + set('refreshRateOpenSec', Number(event.target.value))} + className={inputClass} + /> + + + set('refreshRateClosedSec', Number(event.target.value))} + className={inputClass} + /> + + + + +
+
+ + {canEdit ? ( + + ) : null} + +
+

Appareils

+

+ À saisir dans le portail captif de l’écran sous Advanced > Custom Server,{' '} + sans barre oblique finale : {deviceBaseUrl} +

+ + {devices.length === 0 ? ( +

+ Aucun appareil appairé. Le premier écran qui appelle sera enregistré automatiquement. +

+ ) : ( +
    + {devices.map((device) => ( +
  • + {device.friendlyId} + {device.macAddress} + + {device.lastSeenAt ? `vu ${device.lastSeenAt}` : 'jamais vu'} + {device.fwVersion ? ` · firmware ${device.fwVersion}` : ''} + + + {canEdit ? ( + + + + + ) : null} + + {issuedToken?.id === device.id ? ( +
    +

    + Nouveau jeton — affiché une seule fois : +

    + {issuedToken.token} +

    + L’ancien ne fonctionne plus. Réappairez l’écran par le portail captif. +

    +
    + ) : null} +
  • + ))} +
+ )} +
+ + {canEdit ? ( +
+

Service de traduction

+

+ Traduit une phrase type pour vérifier la configuration, sans créer de message. +

+ +
+ ) : null} +
+ ); +} + +const inputClass = + 'mt-1 w-full rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-2 text-base disabled:opacity-60'; + +function Field({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/app/admin/parametres/actions.ts b/app/admin/parametres/actions.ts new file mode 100644 index 0000000..e36bfb9 --- /dev/null +++ b/app/admin/parametres/actions.ts @@ -0,0 +1,169 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; + +import { diffOf, recordAudit } from '@/lib/audit'; +import { requireAdmin } from '@/lib/auth/actions'; +import { prisma } from '@/lib/db'; +import { generateDeviceToken, hashToken } from '@/lib/device/auth'; +import { MAX_REFRESH_SEC, MIN_REFRESH_SEC } from '@/lib/device/refresh'; +import { translateToEnglish } from '@/lib/translation/service'; + +export type ActionResult = { ok: true; value: T } | { ok: false; error: string }; + +export type SettingsInput = { + shopName: string; + timezone: string; + countryIsoCode: string; + subdivisionCode: string; + refreshRateOpenSec: number; + refreshRateClosedSec: number; + imageFormat: 'bmp' | 'png'; +}; + +export async function saveSettings(input: SettingsInput): Promise { + const gate = await requireAdmin(); + if (!gate.ok) { + return { ok: false, error: gate.error }; + } + + const shopName = input.shopName.trim(); + if (!shopName) { + return { ok: false, error: 'Le nom de la boutique ne peut pas être vide.' }; + } + if (!isValidTimezone(input.timezone)) { + return { ok: false, error: `Fuseau horaire inconnu : « ${input.timezone} ».` }; + } + for (const [label, value] of [ + ['ouverture', input.refreshRateOpenSec], + ['fermeture', input.refreshRateClosedSec], + ] as const) { + if (!Number.isFinite(value) || value < MIN_REFRESH_SEC || value > MAX_REFRESH_SEC) { + return { + ok: false, + error: `L’intervalle en ${label} doit être compris entre ${MIN_REFRESH_SEC} et ${MAX_REFRESH_SEC} secondes.`, + }; + } + } + + const before = await prisma.settings.findUnique({ where: { id: 'singleton' } }); + const data = { + shopName, + timezone: input.timezone.trim(), + countryIsoCode: input.countryIsoCode.trim().toUpperCase(), + subdivisionCode: input.subdivisionCode.trim().toUpperCase(), + refreshRateOpenSec: input.refreshRateOpenSec, + refreshRateClosedSec: input.refreshRateClosedSec, + imageFormat: input.imageFormat, + }; + + await prisma.settings.upsert({ + where: { id: 'singleton' }, + update: data, + create: { id: 'singleton', ...data }, + }); + + await recordAudit({ + userEmail: gate.value.email, + action: 'settings.update', + entity: 'Settings', + diff: diffOf(before ? asAudit(before) : null, asAudit(data)), + }); + + revalidatePath('/admin'); + revalidatePath('/admin/parametres'); + return { ok: true, value: undefined }; +} + +/** + * Issues a fresh token for a panel. + * + * The new token is returned once and never again — only its digest is stored. + * The device has to be re-paired through the captive portal afterwards, which + * is the point: this is what you reach for when a token may have leaked. + */ +export async function regenerateDeviceToken(id: string): Promise> { + const gate = await requireAdmin(); + if (!gate.ok) { + return { ok: false, error: gate.error }; + } + + const device = await prisma.device.findUnique({ where: { id } }); + if (!device) { + return { ok: false, error: 'Cet appareil n’existe plus.' }; + } + + const token = generateDeviceToken(); + await prisma.device.update({ where: { id }, data: { apiKeyHash: hashToken(token) } }); + + await recordAudit({ + userEmail: gate.value.email, + action: 'device.regenerate_token', + entity: 'Device', + entityId: id, + diff: { jeton: { before: 'précédent', after: 'régénéré' } }, + }); + + revalidatePath('/admin/parametres'); + return { ok: true, value: { token } }; +} + +export async function forgetDevice(id: string): Promise { + const gate = await requireAdmin(); + if (!gate.ok) { + return { ok: false, error: gate.error }; + } + + const device = await prisma.device.findUnique({ where: { id } }); + if (!device) { + return { ok: false, error: 'Cet appareil n’existe plus.' }; + } + + await prisma.device.delete({ where: { id } }); + + await recordAudit({ + userEmail: gate.value.email, + action: 'device.forget', + entity: 'Device', + entityId: id, + diff: diffOf({ adresse: device.macAddress, identifiant: device.friendlyId }, null), + }); + + revalidatePath('/admin/parametres'); + return { ok: true, value: undefined }; +} + +/** Round-trips a short phrase so the service can be checked without a message. */ +export async function testTranslation(): Promise> { + const gate = await requireAdmin(); + if (!gate.ok) { + return { ok: false, error: gate.error }; + } + + const outcome = await translateToEnglish('Fermeture exceptionnelle jeudi après-midi'); + return outcome.ok + ? { ok: true, value: { text: outcome.text } } + : { ok: false, error: outcome.error }; +} + +function isValidTimezone(value: string): boolean { + try { + new Intl.DateTimeFormat('en', { timeZone: value.trim() }); + return true; + } catch { + return false; + } +} + +/** Accepts a stored row too, whose imageFormat is a plain string. */ +function asAudit(settings: Omit & { imageFormat: string }): Record { + return { + nom: settings.shopName, + fuseau: settings.timezone, + pays: settings.countryIsoCode, + canton: settings.subdivisionCode, + 'réveil ouvert (s)': settings.refreshRateOpenSec, + 'réveil fermé (s)': settings.refreshRateClosedSec, + 'format image': settings.imageFormat, + }; +} diff --git a/app/admin/parametres/page.tsx b/app/admin/parametres/page.tsx new file mode 100644 index 0000000..9646633 --- /dev/null +++ b/app/admin/parametres/page.tsx @@ -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 ( +
+

Paramètres

+ + + +
+

Journal d’audit

+

+ Les {AUDIT_LIMIT} dernières actions. Qui a changé quoi, et quand. +

+ + {audit.length === 0 ? ( +

Aucune action enregistrée.

+ ) : ( +
    + {audit.map((entry) => ( +
  • +
    + + {entry.createdAt.toLocaleString('fr-CH')} + + {entry.action} + {entry.userEmail} +
    + {entry.diff ? ( +
      + {Object.entries(entry.diff as Record).map( + ([field, change]) => ( +
    • + {field} :{' '} + {render(change.before)} → {render(change.after)} +
    • + ), + )} +
    + ) : null} +
  • + ))} +
+ )} +
+
+ ); +} + +function render(value: unknown): string { + if (value === null || value === undefined) { + return '—'; + } + if (typeof value === 'boolean') { + return value ? 'oui' : 'non'; + } + return String(value); +}