diff --git a/CHANGELOG.md b/CHANGELOG.md index cf815cc..67825a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.16.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.16.1**. --- @@ -14,6 +14,24 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.16.1] — 2026-09-22 + +### Corrigé + +- **BUG-068 - Configuration : section « 🔒 Sécurité du compte » inachevée** : + boutons `config-btn-primary` / `config-btn-danger` définis depuis les + variables du thème (`frontend/style.css`) ; QR code TOTP généré en local par + le backend (`POST /api/auth/mfa/totp/setup` → `qr_data_url`, SVG `data:` + via `segno`, `backend/requirements.txt`) au lieu de l'image tierce bloquée + par la CSP (`img-src 'self' data: blob:`, secret TOTP exposé) ; codes de + récupération affichés aussi à la première activation WebAuthn ; carte + « Mot de passe » (changement via `POST /api/auth/change-password`) et + échappement des libellés de clés WebAuthn. Tests : + `tests/test_mfa.py::test_mfa_setup_returns_local_qr_data_url`, + `tests/frontend/mfa-settings.test.mjs` (nouveau, 9 contrôles). + +--- + ## [2.16.0] — 2026-09-22 ### Modifié diff --git a/README.fr.md b/README.fr.md index 1996f35..c812b06 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.16.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.16.1-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -927,8 +927,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.16.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.16.1). --- -*Projet : ObsiGate | Version : 2.16.0 | Dernière mise à jour : Juin 2026* +*Projet : ObsiGate | Version : 2.16.1 | Dernière mise à jour : Juin 2026* diff --git a/README.md b/README.md index 6061b36..6084f45 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.16.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.16.1-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1096,8 +1096,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.16.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.16.1). --- -*Project: ObsiGate | Version: 2.16.0 | Last updated: May 2026* +*Project: ObsiGate | Version: 2.16.1 | Last updated: May 2026* diff --git a/VERSION b/VERSION index 7524906..0e7079b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.16.0 +2.16.1 diff --git a/backend/auth/router.py b/backend/auth/router.py index 7632392..56709c5 100644 --- a/backend/auth/router.py +++ b/backend/auth/router.py @@ -448,7 +448,9 @@ class MfaEnableRequest(BaseModel): async def mfa_totp_setup(current_user=Depends(require_auth)): """Generate a TOTP secret and QR URI for MFA setup. - Returns the secret and otpauth URI — client displays QR code. + Returns the secret, the otpauth URI and a ready-to-display QR code + (`qr_data_url`, SVG `data:` URI — no third-party service, CSP-safe). + Does NOT enable MFA yet; call /mfa/totp/enable after first successful verify. """ from .user_store import update_user @@ -458,10 +460,21 @@ async def mfa_totp_setup(current_user=Depends(require_auth)): update_user(current_user["username"], { "mfa_secret_pending": secret, }) + # BUG-068: the QR code is generated locally (segno, stdlib-free SVG data + # URI). The previous client-side https://api.qrserver.com image was blocked + # by the CSP (img-src 'self' data: blob:) and leaked the otpauth URI — + # including the TOTP secret — to a third party. + qr_data_url: str | None = None + try: + import segno + qr_data_url = segno.make(qr_uri).svg_data_uri(scale=5) + except Exception: + qr_data_url = None return { "secret": secret, "qr_uri": qr_uri, "otpauth_uri": qr_uri, + "qr_data_url": qr_data_url, } diff --git a/backend/requirements.txt b/backend/requirements.txt index 96220cb..7cbdc04 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,6 +15,7 @@ weasyprint>=60.0 httpx>=0.27.0 pypdf>=4.0 pyotp>=2.10.0 +segno>=1.5.0 webauthn==2.6.0 psutil>=5.9 pywebpush>=2.3.0 diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 073985b..9b90451 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.16.0" +version = "2.16.1" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 9a17d22..bb98302 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.16.0" +version = "2.16.1" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index bd120c1..b23c025 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.16.0", + "version": "2.16.1", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index fb683ca..3f4d1bb 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -176,6 +176,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | *BUG-065* | [🟡 IMPORTANT] Éditeur Excalidraw : l'auto-save recharge la page en pleine édition | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/excalidraw-viewer.js`, `frontend/js/utils.js`, `frontend/excalidraw-editor.html`, `tests/frontend/excalidraw-viewer.test.mjs` | Ouvrir un `.excalidraw` puis modifier un élément : au bout de 2 s la vue se recharge | Chaque modification déclenchait un `PUT save` 2 s plus tard → SSE `index_updated` → `reloadExternalWrite` → `openFile` → **recréation de l'iframe** (refresh visible). Auto-save supprimée : sauvegarde explicite (bouton 💾 / Ctrl+S). `reloadExternalWrite` ignore le fichier si un iframe Excalidraw est ouvert (`iframe[data-excalidraw-vault/path]`). Le badge « Modified » ne réagit plus aux changements d'`appState` (resize/zoom) mais à la signature des éléments. | Vérifié Playwright : plus de refresh, badge stable après bascule plein écran. Test statique (absence de `requestSave`/`saveTimer`). | | *BUG-066* | [🔵 MINEUR] Configuration : icônes manquantes dans la table des matières (« Fichiers cachés », « Partages publics ») | 🟢 corrigé | P3 | 📱 frontend | IA | `frontend/locales/{fr,en}.json` | Ouvrir Configuration → observer le sommaire : les entrées « Fichiers cachés » et « Partages publics » n'ont pas d'icône | `config.section_hidden` → « 🗂️ Fichiers cachés » / « 🗂️ Hidden files », `config.section_shares` → « 📤 Partages publics » (EN avait déjà l'icône). Test : `tests/frontend/unit.test.mjs` (+1 : toutes les entrées du sommaire portent une icône FR/EN) | Les libellés du sommaire utilisent des clés i18n distinctes des titres de section (`auto.f8ba6127`, `config.section_partages-publics`) qui, elles, avaient l'icône | | *BUG-067* | [🔵 MINEUR] Guide d'utilisation : l'entrée « 📱 Mobile » du sommaire ne fait rien (section absente) | 🟢 corrigé | P3 | 📱 frontend | IA | `frontend/index.html` | Ouvrir le Guide → cliquer « 📱 Mobile » dans le sommaire : rien ne se passe | L'ancre `#help-mobile-editor` était présente dans la TOC mais aucune section `id="help-mobile-editor"` n'existait (l'édition mobile n'était qu'un h3 de `help-edition`). Fix #105 : section dédiée créée avec ancre + entrée de nav cohérente. | Vérifié par test statique `tests/test_guide.py::test_nav_anchors_resolve` | +| *BUG-068* | Configuration — section « 🔒 Sécurité du compte » inachevée : boutons hors thème, QR code invisible, fiabilité des fonctions à valider | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/js/auth.js`, `frontend/style.css`, `backend/auth/router.py` | Configuration → 🔒 Sécurité du compte | `frontend/style.css` (+`config-btn-primary`/`danger` thème), `backend/auth/router.py` (`qr_data_url` segno local), `frontend/js/auth.js` (QR local + fallback, recovery WebAuthn, carte mot de passe, escapeHtml labels), locales FR/EN, `backend/requirements.txt` (+segno) ; tests `tests/test_mfa.py` (+1) + `tests/frontend/mfa-settings.test.mjs` (nouveau, 9) | pytest 1241 passed / 6 skipped, ruff 0, mypy 0, frontend unit + validate-imports verts | | | | | | | | | | | | | ### TODOs techniques (améliorations / nouvelles tâches) @@ -249,6 +250,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | 2026-09-18 | BUG-066 | Correction | `frontend/locales/fr.json`, `frontend/locales/en.json`, `tests/frontend/unit.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-066** : la table des matières de la page de configuration n'affichait aucune icône pour « Fichiers cachés » et « Partages publics ». Les libellés du sommaire proviennent de clés i18n (`config.section_hidden`, `config.section_shares`) distinctes des titres de section qui, eux, portaient déjà l'icône. Alignement : 🗂️ / 📤 en FR **et** EN. Test de non-régression : `unit.test.mjs` vérifie que **toutes** les entrées `.help-nav-link` du sommaire portent une icône dans les deux langues (17/17). Vérifié : `unit.test.mjs` 10/10, `validate-imports` 38 modules. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-18 | #105, BUG-067 | Documentation + correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/main.py`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **#105** : audit complet de couverture du Guide d'utilisation — 8 nouvelles sections (Architecture + diagramme Mermaid, API & intégrations, Diagrammes Mermaid & Excalidraw, Hors-ligne & synchronisation, Collaboration temps réel, Application desktop, Bibliothèque & signets, Multilingue) et compléments (recherche sémantique, MFA/WebAuthn, notifications push, exports HTML/ePub/ZIP, PDF, vue multi-panneaux, admin). Téléchargement du guide en Markdown et PDF (`GET /api/guide/download?format=md|pdf`, FR/EN, rendu par le moteur d'export existant). Guide plus large en desktop. **BUG-067** : ancre morte `#help-mobile-editor` → section dédiée créée. | 🟢 corrigé (en attente vérif utilisateur) | 2026-09-18 | #105 (ajustements) | Amélioration | `frontend/index.html`, `frontend/js/config.js`, `frontend/sw.js`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/pdf_export.py`, `Dockerfile`, `scripts/build_guide_diagrams.py`, `scripts/render_guide_diagram.mjs`, `scripts/guide_content.py`, `backend/assets/guide_diagrams/df7366a40db6a5a2.png`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md` | **#105 (retour utilisateur)** : 1) boutons de téléchargement du guide passés en icônes seules (tooltips i18n conservés) ; 2) le diagramme Mermaid de la section Architecture est désormais rendu en **vraie image** dans le PDF (pipeline de pré-rendu PNG Chromium+mermaid v11, PNG commité sous `backend/assets/guide_diagrams/.png`, résolu par `diagram_png_for()` ; le Markdown garde le fenced mermaid) ; 3) emoji du PDF rendus **en couleur** au lieu de rectangles : `fonts-noto-color-emoji` ajouté au Dockerfile + `"Noto Color Emoji"` en fin de pile de polices PDF. Vérifié : pytest 1218 (test_guide ×13), ruff/mypy 0, validate-imports 38, unit 10/10 ; PDF live conteneur 2020 : 24 pages, 0 glyphes tofu, diagramme 3568x1174 embarqué. | 🟢 livré +| 2026-09-22 | BUG-068 | Correction | `backend/auth/router.py`, `backend/requirements.txt`, `frontend/js/auth.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_mfa.py`, `tests/frontend/mfa-settings.test.mjs` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-068** : section « 🔒 Sécurité du compte » finalisée. (1) Boutons hors thème : `config-btn-primary`/`config-btn-danger` n'existaient pas en CSS → définis depuis les variables du thème (+ états disabled). (2) QR invisible : l'image tierce était bloquée par la CSP (`img-src 'self' data: blob:`) et exposait le secret TOTP → QR SVG `data:` généré en local par le backend (`qr_data_url`, segno) avec repli saisie manuelle. (3) Codes de récupération perdus à la 1re activation WebAuthn → `_showRecoveryCodes(codes, targetId)` avec repli `webauthn-flow-area`. (4) Carte « Mot de passe » ajoutée (endpoint `change-password` existant, jusque-là sans UI) + échappement des libellés de clés WebAuthn. Vérifié : pytest 1241 passed / 6 skipped, ruff 0, mypy 0 (78 fichiers), `mfa-settings.test.mjs` 9/9, unit 10/10, validate-imports 39 modules. | 🟢 corrigé (en attente vérif utilisateur) | --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ecca184..d571cf1 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.16.0 | **Dernière mise à jour :** 2026-09-22 +> **Version :** 2.16.1 | **Dernière mise à jour :** 2026-09-22 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** diff --git a/frontend/js/auth.js b/frontend/js/auth.js index 4d5fd77..0c63109 100644 --- a/frontend/js/auth.js +++ b/frontend/js/auth.js @@ -1,6 +1,6 @@ /* ObsiGate — Authentication: API helper, AuthManager, login form, AdminPanel */ import { state } from './state.js'; -import { safeCreateIcons } from './utils.js'; +import { safeCreateIcons, escapeHtml } from './utils.js'; import { showToast, closeHeaderMenu } from './ui.js'; import { t, getLocale, setLocale } from './i18n.js'; import { showWelcome } from './viewer.js'; @@ -266,6 +266,13 @@ const AuthManager = { }); }, + async changePassword(currentPassword, newPassword) { + return await api("/api/auth/change-password", { + method: "POST", + body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }), + }); + }, + async logout() { try { const token = this.getToken(); @@ -1058,11 +1065,79 @@ async function initMfaSettings() { }); } + // Password change (BUG-068: the "Sécurité du compte" section had no way to + // change the password although POST /api/auth/change-password exists). + _renderPasswordSection(area); + // WebAuthn security keys section (ROADMAP #64) _renderWebauthnSection(area); } +function _renderPasswordSection(container) { + if (!container || document.getElementById("password-settings")) return; + const section = document.createElement("div"); + section.id = "password-settings"; + section.className = "password-settings"; + section.innerHTML = ` +

${t("mfa.password_change_title")}

+

${t("mfa.password_change_desc")}

+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + `; + container.appendChild(section); + + section.querySelector("#pwd-change-btn").addEventListener("click", async () => { + const errEl = section.querySelector("#pwd-change-error"); + const current = section.querySelector("#pwd-current").value; + const next = section.querySelector("#pwd-new").value; + const confirm = section.querySelector("#pwd-confirm").value; + const btn = section.querySelector("#pwd-change-btn"); + errEl.classList.add("hidden"); + if (!current || !next || !confirm) { + errEl.textContent = t("mfa.fill_all_fields"); + errEl.classList.remove("hidden"); + return; + } + if (next !== confirm) { + errEl.textContent = t("mfa.password_mismatch"); + errEl.classList.remove("hidden"); + return; + } + btn.disabled = true; + try { + await AuthManager.changePassword(current, next); + showToast(t("mfa.password_changed"), "success"); + section.querySelector("#pwd-current").value = ""; + section.querySelector("#pwd-new").value = ""; + section.querySelector("#pwd-confirm").value = ""; + } catch (err) { + errEl.textContent = err.message || String(err); + errEl.classList.remove("hidden"); + } finally { + btn.disabled = false; + } + }); +} + + async function _renderWebauthnSection(container) { if (!container || !window.PublicKeyCredential) return; @@ -1085,10 +1160,10 @@ async function _renderWebauthnSection(container) { const listHtml = keys.length ? `` : `

${t("mfa.webauthn_none")}

`; @@ -1111,7 +1186,10 @@ async function _renderWebauthnSection(container) { const label = prompt(t("mfa.webauthn_label_prompt"), "Ma clé"); const result = await AuthManager.webauthnRegister(credential, label || "Security key"); if (result.recovery_codes && result.recovery_codes.length) { - _showRecoveryCodes(result.recovery_codes); + // BUG-068: first-time WebAuthn enable issues recovery codes. There is + // no #mfa-setup-flow-area in the "already enabled" view, so render + // them into the WebAuthn flow area instead of losing them. + _showRecoveryCodes(result.recovery_codes, "webauthn-flow-area"); } else { showToast(t("mfa.webauthn_added"), "success"); } @@ -1145,16 +1223,28 @@ async function _startMfaSetup() { try { const data = await AuthManager.mfaSetup(); + // BUG-068: the QR code comes from the backend as a local SVG data: URI + // (see POST /api/auth/mfa/totp/setup → qr_data_url). The previous + // third-party QR image was blocked by the CSP + // (img-src 'self' data: blob:) so it never displayed — and it leaked the + // otpauth URI (TOTP secret) to a third party. Fall back to the manual + // secret when the backend has no QR generator available. + const qrImg = data.qr_data_url + ? `QR Code` + : ""; + const fallbackStyle = data.qr_data_url ? "display:none" : ""; flowArea.innerHTML = `

${t("mfa.scan_qr")}

- QR Code + ${qrImg} +

${t("mfa.qr_unavailable")}

-
+
${t("mfa.manual_entry")} - ${data.secret} + ${escapeHtml(data.secret)}
@@ -1201,12 +1291,16 @@ async function _startMfaSetup() { } -function _showRecoveryCodes(codes) { - const flowArea = document.getElementById("mfa-setup-flow-area"); - const area = document.getElementById("mfa-setup-area"); +function _showRecoveryCodes(codes, targetId) { + // BUG-068: the recovery codes must be visible wherever the enable flow ran. + // The TOTP flow owns #mfa-setup-flow-area, but the WebAuthn first-enable + // path (#webauthn-flow-area) has none — previously those codes were lost. + const flowArea = document.getElementById(targetId || "mfa-setup-flow-area") + || document.getElementById("webauthn-flow-area") + || document.getElementById("mfa-setup-area"); if (!flowArea) return; - const codesHtml = codes.map(c => `${c}`).join("\n"); + const codesHtml = codes.map(c => `${escapeHtml(c)}`).join("\n"); flowArea.innerHTML = `

🔑 ${t("mfa.recovery_codes_title")}

diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 3500653..8326918 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1887,6 +1887,18 @@ "mfa.disable_confirm_btn": "Disable 2FA", "mfa.disabled_success": "2FA has been disabled.", "mfa.fill_all_fields": "Please fill in all fields.", + "mfa.qr_unavailable": "QR code unavailable — use manual entry below.", + "mfa.password_change_title": "Password", + "mfa.password_change_desc": "Change your account password (min. 8 characters). All other sessions are invalidated.", + "mfa.current_password_label": "Current password", + "mfa.current_password_placeholder": "Your current password", + "mfa.new_password_label": "New password", + "mfa.new_password_placeholder": "Min. 8 characters", + "mfa.new_password_confirm_label": "Confirm new password", + "mfa.new_password_confirm_placeholder": "Repeat the new password", + "mfa.password_change_btn": "Change password", + "mfa.password_mismatch": "The two passwords do not match.", + "mfa.password_changed": "Password updated.", "bookslm.title": "BooksLM", "bookslm.files_indexed": "{count} files indexed", "bookslm.chars_loaded": "{chars} chars loaded", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 08fa546..698862d 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1887,6 +1887,18 @@ "mfa.disable_confirm_btn": "Désactiver la 2FA", "mfa.disabled_success": "La 2FA a été désactivée.", "mfa.fill_all_fields": "Veuillez remplir tous les champs.", + "mfa.qr_unavailable": "QR code indisponible — utilisez la saisie manuelle ci-dessous.", + "mfa.password_change_title": "Mot de passe", + "mfa.password_change_desc": "Modifiez le mot de passe de votre compte (min. 8 caractères). Toutes les autres sessions sont invalidées.", + "mfa.current_password_label": "Mot de passe actuel", + "mfa.current_password_placeholder": "Votre mot de passe actuel", + "mfa.new_password_label": "Nouveau mot de passe", + "mfa.new_password_placeholder": "Min. 8 caractères", + "mfa.new_password_confirm_label": "Confirmer le nouveau mot de passe", + "mfa.new_password_confirm_placeholder": "Répétez le nouveau mot de passe", + "mfa.password_change_btn": "Changer le mot de passe", + "mfa.password_mismatch": "Les deux mots de passe ne correspondent pas.", + "mfa.password_changed": "Mot de passe mis à jour.", "bookslm.title": "BooksLM", "bookslm.files_indexed": "{count} fichiers indexés", "bookslm.chars_loaded": "{chars} caractères chargés", diff --git a/frontend/style.css b/frontend/style.css index d3b64aa..bdb09ca 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -4347,6 +4347,76 @@ body.resizing-v { background: var(--bg-hover); } +/* BUG-068: .config-btn-primary / .config-btn-danger were used by the account + security section (frontend/js/auth.js) but never defined — the buttons fell + back to the browser default and ignored the theme. Defined here with the + same conventions as .config-btn-save / .config-btn-secondary. */ +.config-btn-primary { + padding: 8px 16px; + border: 1px solid var(--accent); + border-radius: 6px; + background: var(--accent); + color: #fff; + font-family: "JetBrains Mono", monospace; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: opacity 150ms; +} +.config-btn-primary:hover { + opacity: 0.9; +} +.config-btn-primary:disabled { + opacity: 0.55; + cursor: not-allowed; +} +.config-btn-danger { + padding: 8px 16px; + border: 1px solid var(--danger, #e74c3c); + border-radius: 6px; + background: var(--danger-bg, #3d1a18); + color: var(--danger, #ff7b72); + font-family: "JetBrains Mono", monospace; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: opacity 150ms; +} +.config-btn-danger:hover { + opacity: 0.9; +} +.config-btn-danger:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +/* BUG-068: local QR code (backend SVG data: URI) + password section share the + security-tab card conventions. */ +.mfa-qr-code-img { + max-width: 200px; + border-radius: 8px; + background: #fff; + padding: 8px; +} +.password-settings { + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--border, #333); +} +.password-settings .form-group { + margin: 8px 0; +} +.password-settings .form-group label { + display: block; + font-size: 0.78rem; + color: var(--text-secondary, #aaa); + margin-bottom: 4px; +} +.password-settings .config-input { + width: 100%; + max-width: 320px; +} + /* --- AI keys section: accordion redesign (#104) --- */ .ai-keys-header { display: flex; diff --git a/package.json b/package.json index da7b11b..1cfeacd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.16.0", + "version": "2.16.1", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/frontend/mfa-settings.test.mjs b/tests/frontend/mfa-settings.test.mjs new file mode 100644 index 0000000..1618427 --- /dev/null +++ b/tests/frontend/mfa-settings.test.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +/** + * ObsiGate — Account security section non-regression tests (BUG-068). + * + * Static checks on the "🔒 Sécurité du compte" configuration section: + * - BUG-068a: the TOTP QR code must NOT depend on the third-party + * https://api.qrserver.com service (blocked by the CSP + * `img-src 'self' data: blob:`, so the QR never displayed — and the + * otpauth URI, TOTP secret included, leaked to a third party). The setup + * endpoint returns a local SVG data: URI (qr_data_url) instead. + * - BUG-068b: .config-btn-primary / .config-btn-danger are used by + * frontend/js/auth.js but were never defined — buttons fell back to the + * browser default and ignored the theme. They must exist and derive from + * theme variables. + * - BUG-068c: recovery codes issued on first-time WebAuthn enable were lost + * (no #mfa-setup-flow-area in the "already enabled" view). + * - BUG-068d: the section had no password change although + * POST /api/auth/change-password exists. + * + * Usage: node tests/frontend/mfa-settings.test.mjs + */ + +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.join(__dirname, "..", ".."); + +const auth = readFileSync(path.join(ROOT, "frontend", "js", "auth.js"), "utf8"); +const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8"); +const router = readFileSync(path.join(ROOT, "backend", "auth", "router.py"), "utf8"); +const fr = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8")); +const en = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8")); + +function test(label, fn) { + try { + fn(); + console.log(" ✓ " + label); + } catch (err) { + console.error(" ✗ " + label + "\n " + err.message); + process.exitCode = 1; + } +} + +// ── QR code: local data URI, no third-party service ───────────────────────── +test("auth.js — no external QR service left (CSP blocks it, secret leaks)", () => { + assert.doesNotMatch(auth, /qrserver\.com/, "api.qrserver.com is blocked by img-src and leaks the otpauth URI"); + assert.doesNotMatch(auth, /https:\/\/api\./, "no third-party https://api.* image may carry the TOTP secret"); +}); + +test("auth.js — setup flow renders the backend qr_data_url with a fallback", () => { + assert.match(auth, /qr_data_url/, "the QR must use the backend-provided qr_data_url"); + assert.match(auth, /mfa-qr-fallback/, "a manual-entry fallback must show when no QR is available"); + assert.match(auth, /mfa\.qr_unavailable/, "the fallback needs its i18n string"); +}); + +test("backend — /mfa/totp/setup returns a local qr_data_url", () => { + assert.match(router, /qr_data_url/, "setup must include qr_data_url in its response"); + assert.match(router, /svg_data_uri/, "the QR must be generated locally (segno SVG data URI)"); + assert.match(router, /import segno/, "segno import must stay local with a graceful fallback"); +}); + +// ── Buttons follow the theme ──────────────────────────────────────────────── +for (const cls of ["config-btn-primary", "config-btn-danger"]) { + test(`style.css — .${cls} is defined from theme variables`, () => { + const rule = css.match(new RegExp(`\\.${cls}\\s*\\{([^}]*)\\}`)); + assert.ok(rule, `.${cls} rule not found — buttons fall back to the browser default`); + assert.match(rule[1], /var\(--/, `.${cls} must derive from CSS theme variables, not hardcoded colors`); + }); +} + +test("style.css — themed buttons have disabled states", () => { + assert.match(css, /\.config-btn-primary:disabled/, ".config-btn-primary needs a disabled state"); + assert.match(css, /\.config-btn-danger:disabled/, ".config-btn-danger needs a disabled state"); +}); + +// ── Recovery codes are never lost ─────────────────────────────────────────── +test("auth.js — _showRecoveryCodes falls back to the WebAuthn flow area", () => { + const fn = auth.match(/function _showRecoveryCodes\(codes(?:, targetId)?\) \{([\s\S]*?)\n\}/); + assert.ok(fn, "_showRecoveryCodes helper not found"); + assert.match(fn[1], /webauthn-flow-area/, "codes issued on first WebAuthn enable must render without #mfa-setup-flow-area"); +}); + +// ── Password change lives in the security section ─────────────────────────── +test("auth.js — password change calls POST /api/auth/change-password", () => { + assert.match(auth, /\/api\/auth\/change-password/, "changePassword must hit the existing endpoint"); + assert.match(auth, /_renderPasswordSection/, "the security section must render a password card"); +}); + +test("i18n — password + QR strings exist in FR and EN", () => { + for (const key of [ + "mfa.qr_unavailable", + "mfa.password_change_title", + "mfa.password_change_btn", + "mfa.password_mismatch", + "mfa.password_changed", + ]) { + assert.ok(fr[key], `fr.json missing ${key}`); + assert.ok(en[key], `en.json missing ${key}`); + } +}); + +if (process.exitCode) { + console.error("\nMFA settings tests FAILED"); +} else { + console.log("\nAll MFA settings tests passed."); +} diff --git a/tests/test_mfa.py b/tests/test_mfa.py index 9bed2fc..122a97e 100644 --- a/tests/test_mfa.py +++ b/tests/test_mfa.py @@ -248,6 +248,22 @@ class TestMfaApiEndpoints: assert "otpauth://totp/" in data["otpauth_uri"] assert len(data["secret"]) >= 16 + def test_mfa_setup_returns_local_qr_data_url(self, mfa_client): + """BUG-068: the setup response carries a CSP-safe local QR code. + + The previous client used an https://api.qrserver.com image, blocked by + the CSP (img-src 'self' data: blob:) — the QR never displayed — and + leaking the otpauth URI to a third party. + """ + token, _ = _login(mfa_client) + resp = mfa_client.post("/api/auth/mfa/totp/setup", headers=_auth_headers(token)) + assert resp.status_code == 200 + data = resp.json() + qr_data_url = data.get("qr_data_url") + assert qr_data_url, "setup must return a local qr_data_url" + assert qr_data_url.startswith("data:image/svg+xml"), qr_data_url[:60] + assert "qrserver.com" not in qr_data_url + def test_mfa_enable_flow(self, mfa_client): token, _ = _login(mfa_client) headers = _auth_headers(token)