dd-timer/src/presentation/components/ParametresView.ts
POL Mickaël 8af626dd66 sécurité: audit commercialisation — hardening + 366 tests (24 E2E)
Sandbox Electron, HTTPS ntfy, validation import structurelle,
suppression executeJavaScript, nettoyage memory leaks, try/catch
sur tous les appels electronAPI. 27 nouveaux tests de sécurité
et validation. README mis à jour avec changelog et couverture.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 07:18:28 +02:00

404 lines
16 KiB
TypeScript

import type { CommandBus } from '@application/handlers/CommandBus';
import type { QueryBus } from '@application/handlers/QueryBus';
import type { SettingsResult } from '@application/queries/GetSettings';
import { esc } from '@presentation/helpers/format';
import { Toast } from './Toast';
import { ConfirmModal } from './ConfirmModal';
const NTFY_BASE = 'https://ntfy.mickael-pol.fr';
const NTFY_REDIRECT = 'https://ntfy-redirect.mickael-pol.fr';
const SOUND_OPTIONS: { value: string; label: string; icon: string }[] = [
{ value: 'arpege', label: 'Arpège', icon: 'music_note' },
{ value: 'pulse', label: 'Pulsation', icon: 'pulse_alert' },
{ value: 'fanfare', label: 'Fanfare', icon: 'celebration' },
{ value: 'cloche', label: 'Cloche', icon: 'notifications_active' },
];
export class ParametresView {
private el: HTMLElement | null = null;
private modal: HTMLElement | null = null;
constructor(
private commandBus: CommandBus,
private queryBus: QueryBus,
private playSound?: (name: string) => void,
) {}
render(container: HTMLElement): void {
this.el = document.createElement('div');
this.el.className = 'param-view';
container.appendChild(this.el);
this.updateDOM();
}
update(): void {}
destroy(): void {
this.modal?.remove();
this.modal = null;
this.el?.remove();
this.el = null;
}
private getSettings(): SettingsResult {
return this.queryBus.execute<SettingsResult>({ type: 'get-settings' });
}
private updateDOM(): void {
if (!this.el) return;
const { alarmSound, notifsEnabled, ntfyTopic } = this.getSettings();
let html = '';
// ── Hero ──────────────────────────────────────────────────────
html += `<div class="param-hero">
<div>
<h2 class="param-hero-title">Param\u00e8tres</h2>
<p class="param-hero-sub">Configuration de l'application et des notifications.</p>
</div>
</div>`;
// ── Son d'alarme ──────────────────────────────────────────────
html += `<div class="param-section">
<div class="param-section-header">
<span class="material-symbols-outlined param-section-icon">volume_up</span>
<h3 class="param-section-title">Son d'alarme</h3>
</div>
<p class="param-section-desc">Choisissez le son joué lorsqu'un enclos termine sa session.</p>
<div class="param-sound-grid">`;
for (const opt of SOUND_OPTIONS) {
const isActive = alarmSound === opt.value ? ' active' : '';
html += `<button class="param-sound-card${isActive}" data-sound="${esc(opt.value)}">
<span class="material-symbols-outlined param-sound-icon">${opt.icon}</span>
<span class="param-sound-label">${esc(opt.label)}</span>
</button>`;
}
html += `</div>
<button class="param-test-btn" id="param-test-sound">
<span class="material-symbols-outlined">play_circle</span>
Tester le son
</button>
</div>`;
// ── Notifications PC ──────────────────────────────────────────
html += `<div class="param-section">
<div class="param-section-header">
<span class="material-symbols-outlined param-section-icon">notifications</span>
<h3 class="param-section-title">Notifications PC</h3>
</div>
<p class="param-section-desc">Recevez une notification Windows quand un enclos est pr\u00eat.</p>
<div class="param-toggle-row">
<span class="param-toggle-label">${notifsEnabled ? 'Notifications activ\u00e9es' : 'Notifications d\u00e9sactiv\u00e9es'}</span>
<button class="param-toggle${notifsEnabled ? ' active' : ''}" id="param-notifs-toggle">
<span class="param-toggle-knob"></span>
</button>
</div>
</div>`;
// ── Notifications Mobile ──────────────────────────────────────
const mobileActive = ntfyTopic ? ' active' : '';
html += `<div class="param-section">
<div class="param-section-header">
<span class="material-symbols-outlined param-section-icon">phone_android</span>
<h3 class="param-section-title">Notifications Mobiles</h3>
</div>
<p class="param-section-desc">Recevez une alerte sur votre t\u00e9l\u00e9phone via ntfy, m\u00eame loin de votre PC.</p>
<div class="param-mobile-status">
<span class="param-mobile-dot${mobileActive}"></span>
<span class="param-mobile-text">${ntfyTopic ? 'Connect\u00e9 — notifications actives' : 'Non configur\u00e9'}</span>
</div>
<button class="param-mobile-btn${mobileActive}" id="param-ntfy-btn">
<span class="material-symbols-outlined">${ntfyTopic ? 'settings' : 'add_circle'}</span>
${ntfyTopic ? 'G\u00e9rer la configuration' : 'Activer les notifications mobiles'}
</button>
</div>`;
// ── Données ──────────────────────────────────────────────────
html += `<div class="param-section">
<div class="param-section-header">
<span class="material-symbols-outlined param-section-icon">database</span>
<h3 class="param-section-title">Donn\u00e9es</h3>
</div>
<p class="param-section-desc">Exportez ou importez toutes vos donn\u00e9es (enclos, dragodindes, statistiques, workflows).</p>
<div class="param-data-btns">
<button class="param-data-btn" id="param-export-data">
<span class="material-symbols-outlined">download</span>
Exporter les donn\u00e9es
</button>
<button class="param-data-btn param-data-btn-import" id="param-import-data">
<span class="material-symbols-outlined">upload</span>
Importer les donn\u00e9es
</button>
</div>
</div>`;
this.el.innerHTML = html;
this.bindEvents();
}
private bindEvents(): void {
if (!this.el) return;
// Sound cards
this.el.querySelectorAll<HTMLElement>('.param-sound-card').forEach(card => {
card.addEventListener('click', () => {
const sound = card.dataset['sound']!;
this.commandBus.execute({ type: 'update-settings', alarmSound: sound });
this.updateDOM();
});
});
// Test sound
this.el.querySelector('#param-test-sound')?.addEventListener('click', () => {
const { alarmSound } = this.getSettings();
this.playSound?.(alarmSound);
});
// PC notifications toggle
this.el.querySelector('#param-notifs-toggle')?.addEventListener('click', () => {
const { notifsEnabled } = this.getSettings();
this.commandBus.execute({ type: 'update-settings', notifsEnabled: !notifsEnabled });
this.updateDOM();
});
// Ntfy modal
this.el.querySelector('#param-ntfy-btn')?.addEventListener('click', () => {
this.openNtfyModal();
});
// Export data
this.el.querySelector('#param-export-data')?.addEventListener('click', () => this.exportData());
// Import data
this.el.querySelector('#param-import-data')?.addEventListener('click', () => this.importData());
}
/* ══ Modal ntfy ══ */
private openNtfyModal(): void {
if (this.modal) { this.modal.classList.remove('hidden'); this.renderNtfyModal(); return; }
this.modal = document.createElement('div');
this.modal.className = 'param-modal-overlay';
this.modal.innerHTML = `
<div class="param-modal-box">
<div class="param-modal-header">
<div class="param-modal-header-left">
<span class="material-symbols-outlined" style="color:var(--md-primary)">phone_android</span>
<h2 class="param-modal-title">Notifications mobiles</h2>
</div>
<button class="param-modal-close" id="ntfy-modal-close">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div class="param-modal-body" id="ntfy-modal-body"></div>
<div class="param-modal-footer">
<button class="param-modal-btn-ghost" id="ntfy-modal-footer-close">Fermer</button>
</div>
</div>`;
document.body.appendChild(this.modal);
this.modal.querySelector('#ntfy-modal-close')?.addEventListener('click', () => this.closeNtfyModal());
this.modal.querySelector('#ntfy-modal-footer-close')?.addEventListener('click', () => this.closeNtfyModal());
this.modal.addEventListener('click', (e) => { if (e.target === this.modal) this.closeNtfyModal(); });
this.renderNtfyModal();
}
private closeNtfyModal(): void {
this.modal?.classList.add('hidden');
}
private renderNtfyModal(): void {
const body = this.modal?.querySelector('#ntfy-modal-body');
if (!body) return;
const { ntfyTopic } = this.getSettings();
if (!ntfyTopic) {
body.innerHTML = `
<div class="param-ntfy-intro">
<div class="param-ntfy-intro-card">
<span class="material-symbols-outlined" style="font-size:36px;color:var(--md-primary)">notifications_active</span>
<p>Recevez une alerte sur votre t\u00e9l\u00e9phone quand un enclos est pr\u00eat, m\u00eame si votre PC est loin !</p>
</div>
<button class="param-ntfy-activate" id="ntfy-activate">
<span class="material-symbols-outlined">add_circle</span>
Activer les notifications mobiles
</button>
</div>`;
body.querySelector('#ntfy-activate')?.addEventListener('click', () => this.activateNtfy());
return;
}
// QR codes
const ntfyPlayStore = 'https://play.google.com/store/apps/details?id=io.heckel.ntfy';
const ntfyAppStore = 'https://apps.apple.com/app/ntfy/id1625396347';
const qrDownload = `https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(ntfyPlayStore)}`;
const redirectUrl = `${NTFY_REDIRECT}/?t=${encodeURIComponent(ntfyTopic)}&s=${encodeURIComponent(NTFY_BASE.replace(/^https?:\/\//, ''))}&n=dd-timer`;
const qrSubscribe = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(redirectUrl)}`;
body.innerHTML = `
<div class="param-ntfy-steps">
<div class="param-ntfy-step">
<div class="param-ntfy-step-badge">1</div>
<div class="param-ntfy-step-content">
<h4 class="param-ntfy-step-title">Installer l'app ntfy</h4>
<p class="param-ntfy-step-desc">Scannez ce QR code ou cherchez <strong>ntfy</strong> sur le
<a href="${ntfyPlayStore}" target="_blank">Play Store</a> /
<a href="${ntfyAppStore}" target="_blank">App Store</a></p>
<div class="param-ntfy-qr-wrap">
<img src="${qrDownload}" width="100" height="100" alt="T\u00e9l\u00e9charger ntfy">
</div>
</div>
</div>
<div class="param-ntfy-step">
<div class="param-ntfy-step-badge">2</div>
<div class="param-ntfy-step-content">
<h4 class="param-ntfy-step-title">S'abonner aux notifications</h4>
<p class="param-ntfy-step-desc">Scannez ce QR code avec l'appareil photo de votre t\u00e9l\u00e9phone pour ajouter automatiquement les notifications.</p>
<div class="param-ntfy-qr-wrap param-ntfy-qr-main">
<img src="${qrSubscribe}" width="150" height="150" alt="S'abonner aux notifications">
</div>
</div>
</div>
</div>
<div class="param-ntfy-actions">
<button class="param-ntfy-test-btn" id="ntfy-test">
<span class="material-symbols-outlined">send</span>
Tester
</button>
<button class="param-ntfy-deactivate-btn" id="ntfy-deactivate">
<span class="material-symbols-outlined">link_off</span>
D\u00e9sactiver
</button>
</div>`;
body.querySelector('#ntfy-test')?.addEventListener('click', () => this.testNtfy());
body.querySelector('#ntfy-deactivate')?.addEventListener('click', () => this.deactivateNtfy());
}
private activateNtfy(): void {
const topic = 'dd-' + Math.random().toString(36).slice(2, 8) + '-' + Date.now().toString(36).slice(-4);
this.commandBus.execute({ type: 'update-settings', ntfyTopic: topic });
this.updateDOM();
this.renderNtfyModal();
}
private deactivateNtfy(): void {
this.commandBus.execute({ type: 'update-settings', ntfyTopic: '' });
this.updateDOM();
this.renderNtfyModal();
}
private testNtfy(): void {
const { ntfyTopic } = this.getSettings();
if (!ntfyTopic) return;
const url = `${NTFY_BASE}/${ntfyTopic}`;
(window as any).electronAPI?.sendNtfy?.(url, 'Test alarme', 'Ceci est un test de la notification mobile Minuteur Dragodinde !');
}
/* ══ Backup / Restore ══ */
private async exportData(): Promise<void> {
const api = (window as any).electronAPI;
if (!api?.loadData || !api?.exportFile) return;
try {
const raw = await api.loadData();
if (!raw) {
Toast.show('error', 'Aucune donnée à exporter.');
return;
}
const version = await api.getVersion?.() ?? 'unknown';
const backup = {
app: 'minuteur-dragodinde',
version,
exportedAt: new Date().toISOString(),
data: JSON.parse(raw),
};
const date = new Date().toISOString().slice(0, 10);
const ok = await api.exportFile(JSON.stringify(backup, null, 2), `dd-timer-backup-${date}.json`);
if (ok) {
Toast.show('success', 'Données exportées avec succès.');
}
} catch {
Toast.show('error', 'Erreur lors de l\'export des données.');
}
}
private async importData(): Promise<void> {
const api = (window as any).electronAPI;
if (!api?.importFile || !api?.saveData) return;
try {
const raw = await api.importFile();
if (!raw) return; // dialogue annulé
let parsed: any;
try {
parsed = JSON.parse(raw);
} catch {
Toast.show('error', 'Le fichier sélectionné n\'est pas un JSON valide.');
return;
}
// Validation structurelle des données importées
const validateEnclosData = (data: any): boolean => {
if (!data || typeof data !== 'object' || data === null) return false;
if (!Array.isArray(data.enclos)) return false;
return data.enclos.every((enc: any) =>
enc && typeof enc === 'object' &&
typeof enc.id === 'number' &&
typeof enc.name === 'string' &&
Array.isArray(enc.dragodindes) &&
enc.gaugeLevels && typeof enc.gaugeLevels === 'object' &&
enc.timer && typeof enc.timer === 'object',
);
};
// Validation du format backup
if (parsed.app === 'minuteur-dragodinde' && parsed.data && typeof parsed.data === 'object' && parsed.data !== null) {
if (!validateEnclosData(parsed.data)) {
Toast.show('error', 'Le backup contient des données corrompues ou incomplètes.');
return;
}
const date = parsed.exportedAt ? new Date(parsed.exportedAt).toLocaleDateString('fr-FR') : 'inconnue';
const ok = await ConfirmModal.show(
'Importer les données',
`Ce backup date du ${date} (v${parsed.version ?? '?'}). Toutes vos données actuelles seront remplacées. Continuer ?`,
);
if (!ok) return;
api.saveData(JSON.stringify(parsed.data));
} else if (parsed.enclos && Array.isArray(parsed.enclos)) {
if (!validateEnclosData(parsed)) {
Toast.show('error', 'Le fichier contient des données corrompues ou incomplètes.');
return;
}
const ok = await ConfirmModal.show(
'Importer les données',
'Toutes vos données actuelles seront remplacées. Continuer ?',
);
if (!ok) return;
api.saveData(JSON.stringify(parsed));
} else {
Toast.show('error', 'Format de fichier non reconnu.');
return;
}
Toast.show('success', 'Données importées. Rechargement...');
setTimeout(() => window.location.reload(), 1000);
} catch {
Toast.show('error', 'Erreur lors de l\'import des données.');
}
}
}