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,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<K extends keyof SettingsInput>(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 (
|
||||
<div className="mt-8 space-y-10">
|
||||
{message ? (
|
||||
<p
|
||||
role="status"
|
||||
className={`rounded-[var(--radius-md)] px-4 py-3 text-sm ${
|
||||
message.kind === 'ok'
|
||||
? 'bg-[var(--success-tint)] text-[var(--success)]'
|
||||
: 'bg-[var(--danger-tint)] text-[var(--danger)]'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-[var(--radius-md)] border border-[var(--line)] bg-[var(--surface)] p-5">
|
||||
<h2 className="text-lg">Boutique</h2>
|
||||
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Nom affiché">
|
||||
<input
|
||||
type="text"
|
||||
value={form.shopName}
|
||||
disabled={!canEdit}
|
||||
onChange={(event) => set('shopName', event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Fuseau horaire">
|
||||
<input
|
||||
type="text"
|
||||
value={form.timezone}
|
||||
disabled={!canEdit}
|
||||
onChange={(event) => set('timezone', event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Pays (ISO)">
|
||||
<input
|
||||
type="text"
|
||||
value={form.countryIsoCode}
|
||||
disabled={!canEdit}
|
||||
onChange={(event) => set('countryIsoCode', event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Canton / subdivision"
|
||||
hint="Détermine les jours fériés synchronisés."
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={form.subdivisionCode}
|
||||
disabled={!canEdit}
|
||||
onChange={(event) => set('subdivisionCode', event.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[var(--radius-md)] border border-[var(--line)] bg-[var(--surface)] p-5">
|
||||
<h2 className="text-lg">Écran</h2>
|
||||
<p className="mt-1 text-sm text-[var(--ink-muted)]">
|
||||
L’écran dort entre deux réveils. Un intervalle court garde l’affichage juste, un intervalle
|
||||
long ménage la batterie.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Réveil quand la boutique est ouverte (secondes)">
|
||||
<input
|
||||
type="number"
|
||||
value={form.refreshRateOpenSec}
|
||||
disabled={!canEdit}
|
||||
onChange={(event) => set('refreshRateOpenSec', Number(event.target.value))}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Réveil la nuit et jours fermés (secondes)">
|
||||
<input
|
||||
type="number"
|
||||
value={form.refreshRateClosedSec}
|
||||
disabled={!canEdit}
|
||||
onChange={(event) => set('refreshRateClosedSec', Number(event.target.value))}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Format d’image" hint="Le firmware Seeed référence des .bmp.">
|
||||
<select
|
||||
value={form.imageFormat}
|
||||
disabled={!canEdit}
|
||||
onChange={(event) => set('imageFormat', event.target.value as 'bmp' | 'png')}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="bmp">BMP 1 bit</option>
|
||||
<option value="png">PNG 1 bit</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => run(() => saveSettings(form), 'Paramètres enregistrés.')}
|
||||
className="rounded-[var(--radius-md)] bg-[var(--accent)] px-4 py-2.5 font-medium text-[var(--on-accent)] transition-colors hover:bg-[var(--accent-hover)] disabled:opacity-40"
|
||||
>
|
||||
{pending ? 'Enregistrement…' : 'Enregistrer les paramètres'}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-[var(--radius-md)] border border-[var(--line)] bg-[var(--surface)] p-5">
|
||||
<h2 className="text-lg">Appareils</h2>
|
||||
<p className="mt-1 text-sm text-[var(--ink-muted)]">
|
||||
À saisir dans le portail captif de l’écran sous <em>Advanced > Custom Server</em>,{' '}
|
||||
<strong>sans barre oblique finale</strong> : <code>{deviceBaseUrl}</code>
|
||||
</p>
|
||||
|
||||
{devices.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-[var(--ink-muted)]">
|
||||
Aucun appareil appairé. Le premier écran qui appelle sera enregistré automatiquement.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-4 divide-y divide-[var(--line)]">
|
||||
{devices.map((device) => (
|
||||
<li key={device.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-3">
|
||||
<span className="font-mono">{device.friendlyId}</span>
|
||||
<span className="text-sm text-[var(--ink-muted)]">{device.macAddress}</span>
|
||||
<span className="text-sm text-[var(--ink-muted)]">
|
||||
{device.lastSeenAt ? `vu ${device.lastSeenAt}` : 'jamais vu'}
|
||||
{device.fwVersion ? ` · firmware ${device.fwVersion}` : ''}
|
||||
</span>
|
||||
|
||||
{canEdit ? (
|
||||
<span className="ml-auto flex gap-3 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
setMessage(null);
|
||||
setIssuedToken(null);
|
||||
startTransition(async () => {
|
||||
const result = await regenerateDeviceToken(device.id);
|
||||
if (result.ok) {
|
||||
setIssuedToken({ id: device.id, token: result.value.token });
|
||||
} else {
|
||||
setMessage({ kind: 'error', text: result.error });
|
||||
}
|
||||
});
|
||||
}}
|
||||
className="underline-offset-2 hover:underline disabled:opacity-40"
|
||||
>
|
||||
Régénérer le jeton
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => run(() => forgetDevice(device.id), 'Appareil oublié.')}
|
||||
className="text-[var(--ink-muted)] underline-offset-2 hover:text-[var(--danger)] hover:underline disabled:opacity-40"
|
||||
>
|
||||
Oublier
|
||||
</button>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{issuedToken?.id === device.id ? (
|
||||
<div className="mt-2 w-full rounded-[var(--radius-sm)] border border-[var(--accent)] bg-[var(--accent-tint)] p-3 text-sm">
|
||||
<p className="font-medium text-[var(--accent-ink)]">
|
||||
Nouveau jeton — affiché une seule fois :
|
||||
</p>
|
||||
<code className="mt-1 block break-all">{issuedToken.token}</code>
|
||||
<p className="mt-1 text-[var(--accent-ink)]">
|
||||
L’ancien ne fonctionne plus. Réappairez l’écran par le portail captif.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{canEdit ? (
|
||||
<section className="rounded-[var(--radius-md)] border border-[var(--line)] bg-[var(--surface)] p-5">
|
||||
<h2 className="text-lg">Service de traduction</h2>
|
||||
<p className="mt-1 text-sm text-[var(--ink-muted)]">
|
||||
Traduit une phrase type pour vérifier la configuration, sans créer de message.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
setMessage(null);
|
||||
startTransition(async () => {
|
||||
const result = await testTranslation();
|
||||
setMessage(
|
||||
result.ok
|
||||
? { kind: 'ok', text: `Traduction obtenue : « ${result.value.text} »` }
|
||||
: { kind: 'error', text: result.error },
|
||||
);
|
||||
});
|
||||
}}
|
||||
className="mt-3 rounded-[var(--radius-md)] border border-[var(--line-strong)] px-4 py-2 transition-colors hover:bg-[var(--surface-muted)] disabled:opacity-40"
|
||||
>
|
||||
Tester la traduction
|
||||
</button>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<label className="block text-sm">
|
||||
<span className="block text-[var(--ink-muted)]">{label}</span>
|
||||
{children}
|
||||
{hint ? <span className="mt-1 block text-xs text-[var(--ink-muted)]">{hint}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user