'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, }; }