From 3b3b6d9a3231ad7def908b39ba20d8694feabe3b Mon Sep 17 00:00:00 2001 From: le king fu Date: Fri, 17 Apr 2026 10:18:18 -0400 Subject: [PATCH] feat: feedback hub widget in Settings Logs card (#67) Add an opt-in feedback submission flow that posts to the central feedback-api service. Integrated into the existing LogViewerCard so the same card now covers diagnostics capture (logs) and diagnostics forwarding (feedback). - Rust command `send_feedback` forwards the payload via reqwest, so the Tauri origin never needs a CORS whitelist entry server-side - First submission shows a one-time consent dialog explaining that this is the only app feature that talks to a server besides updates and Maximus sign-in - Three opt-in checkboxes (all unchecked by default): navigation context, recent error logs (appended as a suffix to the content), identify with the Maximus account - Context keys are limited to the server whitelist (page, locale, theme, viewport, userAgent, timestamp); app_version + OS are packed into userAgent via `get_feedback_user_agent` so we don't pull in an extra Tauri plugin - Error codes are stable strings (invalid, rate_limit, server_error, network_error) mapped to i18n messages on the frontend - Wording follows the cross-app convention documented in `la-compagnie-maximus/docs/feedback-hub-ops.md` - CSP `connect-src` extended with feedback.lacompagniemaximus.com - Docs: CHANGELOG (EN + FR), guide utilisateur, docs.settings (features/steps/tips) updated in both locales Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.fr.md | 1 + CHANGELOG.md | 1 + docs/guide-utilisateur.md | 11 +- src-tauri/src/commands/feedback_commands.rs | 159 ++++++++++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/lib.rs | 2 + src-tauri/tauri.conf.json | 2 +- .../settings/FeedbackConsentDialog.tsx | 67 +++++ src/components/settings/FeedbackDialog.tsx | 239 ++++++++++++++++++ src/components/settings/LogViewerCard.tsx | 38 ++- src/hooks/useFeedback.test.ts | 44 ++++ src/hooks/useFeedback.ts | 68 +++++ src/i18n/locales/en.json | 47 +++- src/i18n/locales/fr.json | 47 +++- src/services/feedbackService.test.ts | 20 ++ src/services/feedbackService.ts | 50 ++++ src/services/logService.test.ts | 56 ++++ src/services/logService.ts | 15 ++ 18 files changed, 847 insertions(+), 22 deletions(-) create mode 100644 src-tauri/src/commands/feedback_commands.rs create mode 100644 src/components/settings/FeedbackConsentDialog.tsx create mode 100644 src/components/settings/FeedbackDialog.tsx create mode 100644 src/hooks/useFeedback.test.ts create mode 100644 src/hooks/useFeedback.ts create mode 100644 src/services/feedbackService.test.ts create mode 100644 src/services/feedbackService.ts create mode 100644 src/services/logService.test.ts diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 7eaeefe..ea6baba 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -3,6 +3,7 @@ ## [Non publié] ### Ajouté +- **Widget Feedback Hub** (Paramètres → Journaux) : un bouton *Envoyer un feedback* dans la carte Journaux ouvre un dialogue pour soumettre suggestions, commentaires ou rapports de bogue vers le Feedback Hub central. Un dialogue de consentement (affiché une seule fois) explique que l'envoi atteint `feedback.lacompagniemaximus.com` — une exception explicite au fonctionnement 100 % local de l'app. Trois cases à cocher opt-in (toutes décochées par défaut) : inclure le contexte de navigation (page, thème, écran, version, OS), inclure les derniers logs d'erreur, m'identifier avec mon compte Maximus. L'envoi passe par une commande Rust côté backend, donc rien ne quitte la machine tant que l'utilisateur n'a pas cliqué *Envoyer* (#67) - **Rapport Cartes** (`/reports/cartes`) : nouveau sous-rapport de type tableau de bord dans le hub Rapports. Combine quatre cartes KPI (Revenus, Dépenses, Solde net, Taux d'épargne) affichant les deltas MoM et YoY simultanément avec une sparkline 13 mois dont le mois de référence est mis en évidence, un graphique overlay revenus vs dépenses sur 12 mois (barres + ligne de solde net), le top 5 des catégories en hausse et en baisse par rapport au mois précédent, une carte d'adhérence au budget (N/M dans la cible plus les 3 pires dépassements avec barres de progression) et une carte de saisonnalité qui compare le mois de référence à la moyenne du même mois sur les deux années précédentes. Toutes les données proviennent d'un seul appel `getCartesSnapshot()` qui exécute ses requêtes en parallèle (#97) ### Modifié diff --git a/CHANGELOG.md b/CHANGELOG.md index 52c4a1f..d21b2b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Added +- **Feedback Hub widget** (Settings → Logs): a *Send feedback* button in the Logs card opens a dialog to submit suggestions, comments, or bug reports to the central Feedback Hub. A one-time consent prompt explains that submission reaches `feedback.lacompagniemaximus.com` — an explicit exception to the app's 100% local operation. Three opt-in checkboxes (all unchecked by default): include navigation context (page, theme, viewport, app version, OS), include recent error logs, identify with your Maximus account. Routed through a Rust-side command so nothing is sent unless you press *Send* (#67) - **Cartes report** (`/reports/cartes`): new dashboard-style sub-report in the Reports hub. Combines four KPI cards (income, expenses, net balance, savings rate) showing MoM and YoY deltas simultaneously with a 13-month sparkline highlighting the reference month, a 12-month income vs. expenses overlay chart (bars + net balance line), top 5 category increases and top 5 decreases vs. the previous month, a budget-adherence card (N/M on-target plus the three worst overruns with progress bars), and a seasonality card that compares the reference month against the same calendar month from the two previous years. All data comes from a single `getCartesSnapshot()` service call that runs its queries concurrently (#97) ### Changed diff --git a/docs/guide-utilisateur.md b/docs/guide-utilisateur.md index b1f9e1b..e93b067 100644 --- a/docs/guide-utilisateur.md +++ b/docs/guide-utilisateur.md @@ -309,6 +309,7 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc - Guide d'utilisation complet accessible directement depuis les paramètres - Vérification automatique des mises à jour avec installation en un clic - Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement +- Envoi de feedback optionnel vers `feedback.lacompagniemaximus.com` (exception explicite au fonctionnement 100 % local — déclenche une demande de consentement avant le premier envoi) - Export des données (transactions, catégories, ou les deux) en format JSON ou CSV - Import des données depuis un fichier exporté précédemment - Chiffrement AES-256-GCM optionnel pour les fichiers exportés @@ -318,9 +319,10 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc 1. Cliquez sur Guide d'utilisation pour accéder à la documentation complète 2. Cliquez sur Vérifier les mises à jour pour voir si une nouvelle version est disponible 3. Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez -4. Utilisez la section Gestion des données pour exporter ou importer vos données -5. Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement -6. Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe +4. Pour partager une suggestion ou signaler un problème, cliquez sur Envoyer un feedback dans la carte Journaux ; les cases d'identification et d'ajout du contexte/logs sont décochées par défaut +5. Utilisez la section Gestion des données pour exporter ou importer vos données +6. Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement +7. Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe ### Astuces @@ -329,4 +331,5 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc - Exportez régulièrement pour garder une sauvegarde de vos données - Le guide d'utilisation peut être imprimé ou exporté en PDF via le bouton Imprimer - Les journaux persistent pendant la session — ils survivent à un rafraîchissement de la page -- En cas de problème, copiez les journaux et joignez-les à votre signalement +- Le feedback est la seule fonctionnalité qui communique avec un serveur en dehors des mises à jour et de la connexion Maximus — chaque envoi est explicite, aucune télémétrie automatique +- En cas de problème, cliquez Envoyer un feedback et cochez « Inclure les derniers logs d'erreur » pour joindre les journaux récents automatiquement diff --git a/src-tauri/src/commands/feedback_commands.rs b/src-tauri/src/commands/feedback_commands.rs new file mode 100644 index 0000000..1818a04 --- /dev/null +++ b/src-tauri/src/commands/feedback_commands.rs @@ -0,0 +1,159 @@ +// Feedback Hub client — forwards user-submitted feedback to the central +// feedback-api service. Routed through Rust (not direct fetch) so that: +// - CORS is bypassed (Tauri origin is not whitelisted server-side by design) +// - The exact payload leaving the machine is auditable in a single place +// - The pattern matches the other outbound calls (OAuth, license, updater) +// +// The feedback-api contract is documented in +// `la-compagnie-maximus/docs/feedback-hub-ops.md`. The server silently drops +// any context key outside its whitelist, so this module only sends the +// fields declared in `Context` below. + +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +fn feedback_endpoint() -> String { + std::env::var("FEEDBACK_HUB_URL") + .unwrap_or_else(|_| "https://feedback.lacompagniemaximus.com".to_string()) +} + +/// Context payload sent with a feedback submission. Keys MUST match the +/// server whitelist in `feedback-api/index.js` — unknown keys are dropped +/// silently. Each field is capped at 500 chars server-side. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct FeedbackContext { + #[serde(skip_serializing_if = "Option::is_none")] + pub page: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub locale: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub theme: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub viewport: Option, + #[serde(rename = "userAgent", skip_serializing_if = "Option::is_none")] + pub user_agent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option, +} + +#[derive(Debug, Serialize)] +struct FeedbackPayload<'a> { + app_id: &'a str, + content: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + user_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + context: Option, +} + +#[derive(Debug, Serialize)] +pub struct FeedbackSuccess { + pub id: String, + pub created_at: String, +} + +#[derive(Debug, Deserialize)] +struct FeedbackResponse { + id: String, + created_at: String, +} + +/// Return a composed User-Agent string for the context payload, e.g. +/// `"Simpl'Résultat/0.8.1 (linux)"`. Uses std::env::consts::OS so we don't +/// pull in an extra Tauri plugin just for this. +#[tauri::command] +pub fn get_feedback_user_agent(app: tauri::AppHandle) -> String { + let version = app.package_info().version.to_string(); + let os = std::env::consts::OS; + format!("Simpl'Résultat/{} ({})", version, os) +} + +/// Submit a feedback to the Feedback Hub. Error strings are stable codes +/// ("invalid", "rate_limit", "server_error", "network_error") that the +/// frontend maps to i18n messages. +#[tauri::command] +pub async fn send_feedback( + content: String, + user_id: Option, + context: Option, +) -> Result { + let trimmed = content.trim(); + if trimmed.is_empty() { + return Err("invalid".to_string()); + } + + let payload = FeedbackPayload { + app_id: "simpl-resultat", + content: trimmed, + user_id, + context, + }; + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(15)) + .build() + .map_err(|_| "network_error".to_string())?; + + let url = format!("{}/api/feedback", feedback_endpoint()); + let res = client + .post(&url) + .json(&payload) + .send() + .await + .map_err(|_| "network_error".to_string())?; + + match res.status().as_u16() { + 201 => { + let body: FeedbackResponse = res + .json() + .await + .map_err(|_| "server_error".to_string())?; + Ok(FeedbackSuccess { + id: body.id, + created_at: body.created_at, + }) + } + 400 => Err("invalid".to_string()), + 429 => Err("rate_limit".to_string()), + _ => Err("server_error".to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn context_skips_none_fields() { + let ctx = FeedbackContext { + page: Some("/settings".to_string()), + locale: Some("fr".to_string()), + theme: None, + viewport: None, + user_agent: None, + timestamp: None, + }; + let json = serde_json::to_value(&ctx).unwrap(); + let obj = json.as_object().unwrap(); + assert_eq!(obj.len(), 2); + assert!(obj.contains_key("page")); + assert!(obj.contains_key("locale")); + } + + #[test] + fn context_serializes_user_agent_camelcase() { + let ctx = FeedbackContext { + user_agent: Some("Simpl'Résultat/0.8.1 (linux)".to_string()), + ..Default::default() + }; + let json = serde_json::to_string(&ctx).unwrap(); + assert!(json.contains("\"userAgent\"")); + assert!(!json.contains("\"user_agent\"")); + } + + #[tokio::test] + async fn empty_content_is_rejected_locally() { + let res = send_feedback(" \n\t".to_string(), None, None).await; + assert_eq!(res.unwrap_err(), "invalid"); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 45c17a1..7013985 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod account_cache; pub mod auth_commands; pub mod entitlements; pub mod export_import_commands; +pub mod feedback_commands; pub mod fs_commands; pub mod license_commands; pub mod profile_commands; @@ -10,6 +11,7 @@ pub mod token_store; pub use auth_commands::*; pub use entitlements::*; pub use export_import_commands::*; +pub use feedback_commands::*; pub use fs_commands::*; pub use license_commands::*; pub use profile_commands::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5d8de27..80cabec 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -185,6 +185,8 @@ pub fn run() { commands::check_subscription_status, commands::logout, commands::get_token_store_mode, + commands::send_feedback, + commands::get_feedback_user_agent, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7bc4904..b8060fe 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -18,7 +18,7 @@ } ], "security": { - "csp": "default-src 'self'; script-src 'self'; connect-src 'self' https://api.lacompagniemaximus.com https://auth.lacompagniemaximus.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:" + "csp": "default-src 'self'; script-src 'self'; connect-src 'self' https://api.lacompagniemaximus.com https://auth.lacompagniemaximus.com https://feedback.lacompagniemaximus.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:" } }, "bundle": { diff --git a/src/components/settings/FeedbackConsentDialog.tsx b/src/components/settings/FeedbackConsentDialog.tsx new file mode 100644 index 0000000..60b441f --- /dev/null +++ b/src/components/settings/FeedbackConsentDialog.tsx @@ -0,0 +1,67 @@ +import { useEffect } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { X, Globe } from "lucide-react"; + +interface FeedbackConsentDialogProps { + onAccept: () => void; + onCancel: () => void; +} + +export default function FeedbackConsentDialog({ + onAccept, + onCancel, +}: FeedbackConsentDialogProps) { + const { t } = useTranslation(); + + useEffect(() => { + function handleEscape(e: KeyboardEvent) { + if (e.key === "Escape") onCancel(); + } + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + }, [onCancel]); + + return createPortal( +
{ + if (e.target === e.currentTarget) onCancel(); + }} + > +
+
+

+ + {t("feedback.consent.title")} +

+ +
+
+

{t("feedback.consent.body")}

+
+
+ + +
+
+
, + document.body, + ); +} diff --git a/src/components/settings/FeedbackDialog.tsx b/src/components/settings/FeedbackDialog.tsx new file mode 100644 index 0000000..93ed4ed --- /dev/null +++ b/src/components/settings/FeedbackDialog.tsx @@ -0,0 +1,239 @@ +import { useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { useLocation } from "react-router-dom"; +import { X, MessageSquarePlus, CheckCircle, AlertCircle } from "lucide-react"; +import { useFeedback } from "../../hooks/useFeedback"; +import { useAuth } from "../../hooks/useAuth"; +import { getRecentErrorLogs } from "../../services/logService"; +import { + getFeedbackUserAgent, + type FeedbackContext, +} from "../../services/feedbackService"; + +const MAX_CONTENT_LENGTH = 2000; +const LOGS_SUFFIX_MAX = 800; +const RECENT_ERROR_LOGS_N = 20; +const AUTO_CLOSE_DELAY_MS = 2000; + +interface FeedbackDialogProps { + onClose: () => void; +} + +export default function FeedbackDialog({ onClose }: FeedbackDialogProps) { + const { t, i18n } = useTranslation(); + const location = useLocation(); + const { state: authState } = useAuth(); + const { state: feedbackState, submit, reset } = useFeedback(); + + const [content, setContent] = useState(""); + const [includeContext, setIncludeContext] = useState(false); + const [includeLogs, setIncludeLogs] = useState(false); + const [identify, setIdentify] = useState(false); + + const isAuthenticated = authState.status === "authenticated"; + const userEmail = authState.account?.email ?? null; + + const trimmed = content.trim(); + const isSending = feedbackState.status === "sending"; + const isSuccess = feedbackState.status === "success"; + const canSubmit = trimmed.length > 0 && !isSending && !isSuccess; + + useEffect(() => { + function handleEscape(e: KeyboardEvent) { + if (e.key === "Escape" && !isSending) onClose(); + } + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + }, [onClose, isSending]); + + // Auto-close after success + useEffect(() => { + if (!isSuccess) return; + const timer = setTimeout(() => { + onClose(); + reset(); + }, AUTO_CLOSE_DELAY_MS); + return () => clearTimeout(timer); + }, [isSuccess, onClose, reset]); + + const errorMessage = useMemo(() => { + if (feedbackState.status !== "error" || !feedbackState.errorCode) return null; + switch (feedbackState.errorCode) { + case "rate_limit": + return t("feedback.toast.error.429"); + case "invalid": + return t("feedback.toast.error.400"); + case "network_error": + case "server_error": + default: + return t("feedback.toast.error.generic"); + } + }, [feedbackState, t]); + + async function handleSubmit() { + if (!canSubmit) return; + + // Compose content with optional logs suffix, keeping total ≤ 2000 chars + let body = trimmed; + if (includeLogs) { + const rawLogs = getRecentErrorLogs(RECENT_ERROR_LOGS_N); + if (rawLogs.length > 0) { + const trimmedLogs = rawLogs.slice(-LOGS_SUFFIX_MAX); + const suffix = `\n\n---\n${t("feedback.logsHeading")}\n${trimmedLogs}`; + const available = MAX_CONTENT_LENGTH - body.length; + if (available > suffix.length) { + body = body + suffix; + } else if (available > 50) { + body = body + suffix.slice(0, available); + } + } + } + + // Compose context if opted in + let context: FeedbackContext | undefined; + if (includeContext) { + let userAgent = "Simpl'Résultat"; + try { + userAgent = await getFeedbackUserAgent(); + } catch { + // fall back to a basic string if the Rust helper fails + } + context = { + page: location.pathname, + locale: i18n.language, + theme: document.documentElement.classList.contains("dark") ? "dark" : "light", + viewport: `${window.innerWidth}x${window.innerHeight}`, + userAgent, + timestamp: new Date().toISOString(), + }; + } + + const userId = identify && isAuthenticated ? userEmail : null; + + await submit({ content: body, userId, context }); + } + + return createPortal( +
{ + if (e.target === e.currentTarget && !isSending) onClose(); + }} + > +
+
+

+ + {t("feedback.dialog.title")} +

+ +
+ +
+ {isSuccess ? ( +
+ + {t("feedback.toast.success")} +
+ ) : ( + <> +