From 3b1c41c48e4716ecca315782e11f0fe5f04f4ad3 Mon Sep 17 00:00:00 2001 From: le king fu Date: Tue, 14 Apr 2026 08:18:41 -0400 Subject: [PATCH 1/2] feat: settings banner when OAuth tokens fall back to file store (#81) Adds a visible warning in the Settings page when `token_store` has landed in the file fallback instead of the OS keychain. Without this, a user on a keychain-less system would silently lose the security benefit introduced in #78 and never know. - New `get_token_store_mode` service wrapper in authService.ts. - New `TokenStoreFallbackBanner` component: fetches the mode on mount, renders nothing when mode is `keychain` or null, renders an amber warning card when mode is `file`. - Mounted in SettingsPage right after AccountCard so it sits next to the account state the user can fix (log out + log back in once the keychain is available). - i18n keys under `account.tokenStore.fallback.*` in fr/en. Refs #66 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../settings/TokenStoreFallbackBanner.tsx | 39 +++++++++++++++++++ src/i18n/locales/en.json | 8 +++- src/i18n/locales/fr.json | 16 +++++--- src/pages/SettingsPage.tsx | 5 +++ src/services/authService.ts | 6 +++ 5 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 src/components/settings/TokenStoreFallbackBanner.tsx diff --git a/src/components/settings/TokenStoreFallbackBanner.tsx b/src/components/settings/TokenStoreFallbackBanner.tsx new file mode 100644 index 0000000..26db4b3 --- /dev/null +++ b/src/components/settings/TokenStoreFallbackBanner.tsx @@ -0,0 +1,39 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ShieldAlert } from "lucide-react"; +import { getTokenStoreMode, TokenStoreMode } from "../../services/authService"; + +export default function TokenStoreFallbackBanner() { + const { t } = useTranslation(); + const [mode, setMode] = useState(null); + + useEffect(() => { + let cancelled = false; + getTokenStoreMode() + .then((m) => { + if (!cancelled) setMode(m); + }) + .catch(() => { + if (!cancelled) setMode(null); + }); + return () => { + cancelled = true; + }; + }, []); + + if (mode !== "file") return null; + + return ( +
+ +
+

+ {t("account.tokenStore.fallback.title")} +

+

+ {t("account.tokenStore.fallback.description")} +

+
+
+ ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ce2bbb1..fca5cd5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -882,6 +882,12 @@ "description": "Sign in to access Premium features (web version, sync). The account is only required for Premium features.", "signIn": "Sign in", "signOut": "Sign out", - "connected": "Connected" + "connected": "Connected", + "tokenStore": { + "fallback": { + "title": "Tokens stored in plaintext fallback", + "description": "Your authentication tokens are currently stored in a local file protected by filesystem permissions. For stronger protection via the OS keychain, make sure a keyring service is running (GNOME Keyring, KWallet, or equivalent)." + } + } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 1b0fbff..5a0669e 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -359,7 +359,7 @@ "byCategory": "Dépenses par catégorie", "overTime": "Catégories dans le temps", "trends": "Tendances mensuelles", - "budgetVsActual": "Budget vs R\u00e9el", + "budgetVsActual": "Budget vs Réel", "subtotalsOnTop": "Sous-totaux en haut", "subtotalsOnBottom": "Sous-totaux en bas", "detail": { @@ -376,9 +376,9 @@ "bva": { "monthly": "Mensuel", "ytd": "Cumul annuel", - "dollarVar": "$ \u00c9cart", - "pctVar": "% \u00c9cart", - "noData": "Aucune donn\u00e9e de budget ou de transaction pour cette p\u00e9riode.", + "dollarVar": "$ Écart", + "pctVar": "% Écart", + "noData": "Aucune donnée de budget ou de transaction pour cette période.", "titlePrefix": "Budget vs Réel pour le mois de" }, "dynamic": "Rapport dynamique", @@ -882,6 +882,12 @@ "description": "Connectez-vous pour accéder aux fonctionnalités Premium (version web, synchronisation). Le compte est requis uniquement pour les fonctionnalités Premium.", "signIn": "Se connecter", "signOut": "Se déconnecter", - "connected": "Connecté" + "connected": "Connecté", + "tokenStore": { + "fallback": { + "title": "Stockage des tokens en clair", + "description": "Vos jetons d'authentification sont stockés dans un fichier local protégé par les permissions du système. Pour une protection renforcée via le trousseau du système d'exploitation, vérifiez que le service de trousseau est disponible (GNOME Keyring, KWallet, ou équivalent)." + } + } } } diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index b09ae1f..760bbd4 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -22,6 +22,7 @@ import DataManagementCard from "../components/settings/DataManagementCard"; import LicenseCard from "../components/settings/LicenseCard"; import AccountCard from "../components/settings/AccountCard"; import LogViewerCard from "../components/settings/LogViewerCard"; +import TokenStoreFallbackBanner from "../components/settings/TokenStoreFallbackBanner"; export default function SettingsPage() { const { t, i18n } = useTranslation(); @@ -80,6 +81,10 @@ export default function SettingsPage() { {/* Account card */} + {/* Security banner — renders only when OAuth tokens are in the + file fallback instead of the OS keychain */} + + {/* About card */}
diff --git a/src/services/authService.ts b/src/services/authService.ts index 845e961..c202a87 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -26,3 +26,9 @@ export async function checkSubscriptionStatus(): Promise { export async function logoutAccount(): Promise { return invoke("logout"); } + +export type TokenStoreMode = "keychain" | "file"; + +export async function getTokenStoreMode(): Promise { + return invoke("get_token_store_mode"); +} From 9a9d3c89b988146feb8819f0f88d2b1163c8a255 Mon Sep 17 00:00:00 2001 From: le king fu Date: Tue, 14 Apr 2026 08:20:20 -0400 Subject: [PATCH 2/2] feat: dismissable banner with session-storage memory (#81) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a close button and session-scoped dismissal flag so the banner can be acknowledged for the current run but reappears on the next app launch if the fallback is still active — matches the #81 acceptance criterion. - sessionStorage key survives page navigation within the run, is cleared on app restart. - Graceful on storage quota errors. - New `common.close` i18n key (FR: "Fermer", EN: "Close") used as the aria-label of the close button. --- .../settings/TokenStoreFallbackBanner.tsx | 36 +++++++++++++++++-- src/i18n/locales/en.json | 3 +- src/i18n/locales/fr.json | 3 +- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/components/settings/TokenStoreFallbackBanner.tsx b/src/components/settings/TokenStoreFallbackBanner.tsx index 26db4b3..5091539 100644 --- a/src/components/settings/TokenStoreFallbackBanner.tsx +++ b/src/components/settings/TokenStoreFallbackBanner.tsx @@ -1,11 +1,23 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { ShieldAlert } from "lucide-react"; +import { ShieldAlert, X } from "lucide-react"; import { getTokenStoreMode, TokenStoreMode } from "../../services/authService"; +// Per-session dismissal flag. Kept in sessionStorage so the banner +// returns on the next app launch if the fallback condition still +// holds — this matches the acceptance criteria from issue #81. +const DISMISS_KEY = "tokenStoreFallbackBannerDismissed"; + export default function TokenStoreFallbackBanner() { const { t } = useTranslation(); const [mode, setMode] = useState(null); + const [dismissed, setDismissed] = useState(() => { + try { + return sessionStorage.getItem(DISMISS_KEY) === "1"; + } catch { + return false; + } + }); useEffect(() => { let cancelled = false; @@ -21,12 +33,22 @@ export default function TokenStoreFallbackBanner() { }; }, []); - if (mode !== "file") return null; + if (mode !== "file" || dismissed) return null; + + const dismiss = () => { + try { + sessionStorage.setItem(DISMISS_KEY, "1"); + } catch { + // Ignore storage errors — the banner will simply hide for the + // remainder of this render cycle via state. + } + setDismissed(true); + }; return (
-
+

{t("account.tokenStore.fallback.title")}

@@ -34,6 +56,14 @@ export default function TokenStoreFallbackBanner() { {t("account.tokenStore.fallback.description")}

+
); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index fca5cd5..c6fad2c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -847,7 +847,8 @@ "language": "Language", "total": "Total", "darkMode": "Dark mode", - "lightMode": "Light mode" + "lightMode": "Light mode", + "close": "Close" }, "license": { "title": "License", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 5a0669e..7ae66b4 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -847,7 +847,8 @@ "language": "Langue", "total": "Total", "darkMode": "Mode sombre", - "lightMode": "Mode clair" + "lightMode": "Mode clair", + "close": "Fermer" }, "license": { "title": "Licence",