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); +}