feat(auth): v2.1.0 - MFA TOTP avec QR code, recovery codes
CI / lint (push) Failing after 16s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 21s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

- backend/auth/mfa.py: TOTP (pyotp), recovery codes SHA-256
- Login flow: mfa_required → totp/verify → token (ou recovery)
- 6 nouveaux endpoints /api/auth/mfa/*
- Frontend: QR code setup, 6-digit auto-submit, recovery codes
- Settings: section Sécurité avec enable/disable MFA
- CSS: mfa-challenge, setup-card, recovery-list, badges
- i18n: 36 nouvelles clés EN/FR
- pyotp ajouté aux dépendances
- 30 tests (TOTP, recovery, API endpoints, login flow)
- 439 tests passent au total
This commit is contained in:
2026-09-06 18:42:32 -04:00
parent 7b0dacabdd
commit 83063d3a18
11 changed files with 1569 additions and 67 deletions
+108 -22
View File
@@ -1,5 +1,5 @@
// config.js — extracted from app.js (3872-4865)
import { api, AuthManager } from './auth.js';
import { api, AuthManager, initMfaSettings } from './auth.js';
import { state } from './state.js';
import { el, icon, openFile } from './viewer.js';
import { syncVaultSelectors, setSelectedVaultContext, refreshSidebarForContext, loadVaults, loadVaultSettings, loadTags, TagFilterService, refreshSidebarTreePreservingState } from './sidebar.js';
@@ -663,6 +663,9 @@ function initConfigModal() {
// Init profile
initProfile();
// Init MFA settings (security tab)
initMfaSettings();
}
function closeConfigModal() {
@@ -1414,25 +1417,40 @@ export {
function initThemePicker() {
import('./themes.js').then(function(mod) {
var THEMES = mod.THEMES;
var AVAILABLE_MODES = mod.AVAILABLE_MODES;
var applyTheme = mod.applyTheme;
var getCurrentTheme = mod.getCurrentTheme;
var getCurrentMode = mod.getCurrentMode;
var exportAllThemes = mod.exportAllThemes;
var importTheme = mod.importTheme;
var deleteCustomTheme = mod.deleteCustomTheme;
var loadCustomThemes = mod.loadCustomThemes;
var grid = document.getElementById('theme-grid');
if (!grid) return;
var themeKeys = Object.keys(THEMES);
// Mode label map (uses i18n t() when available, fallback to English)
var modeLabels = {
'dark': function() { return t('theme.dark'); },
'light': function() { return t('theme.light'); },
'high-contrast': function() { return t('theme.high_contrast'); },
'sepia': function() { return t('theme.sepia'); }
};
function renderCards() {
var themeKeys = Object.keys(THEMES);
var current = getCurrentTheme();
var mode = getCurrentMode();
grid.innerHTML = '';
themeKeys.forEach(function(key) {
var t = THEMES[key];
var v = t.modes[mode];
var th = THEMES[key];
// Use dark mode for preview if mode not resolved yet
var v = th.modes[mode] || th.modes.dark || th.modes.light;
if (!v) return;
var card = document.createElement('div');
card.className = 'theme-card' + (key === current ? ' active' : '');
var isCustom = th.desc === 'Custom theme';
card.innerHTML =
'<div class="theme-card-preview">' +
'<div class="theme-card-preview-bar" style="background:' + (v['--bg-sidebar'] || v['--bg-primary']) + '"></div>' +
@@ -1440,33 +1458,101 @@ function initThemePicker() {
'<div class="theme-card-preview-btn" style="background:' + (v['--accent'] || '#58a6ff') + '"></div>' +
'</div>' +
'</div>' +
'<div class="theme-card-name">' + t.name + '</div>' +
'<div class="theme-card-desc">' + t.desc + '</div>';
'<div class="theme-card-name">' + th.name + (isCustom ? ' <span class="theme-custom-badge">✦</span>' : '') + '</div>' +
'<div class="theme-card-desc">' + th.desc + '</div>';
card.addEventListener('click', function() {
applyTheme(key, mode);
renderCards();
});
// Right-click to delete custom themes
if (isCustom) {
card.addEventListener('contextmenu', function(e) {
e.preventDefault();
if (confirm(t('theme.delete_confirm'))) {
deleteCustomTheme(key);
renderCards();
}
});
}
grid.appendChild(card);
});
// Mode toggle
// Mode toggle — 4 modes
var toggle = document.createElement('div');
toggle.className = 'theme-mode-toggle';
var dBtn = elBtn(t('theme.dark'), 'dark', mode);
var lBtn = elBtn(t('theme.light'), 'light', mode);
toggle.appendChild(dBtn);
toggle.appendChild(lBtn);
grid.appendChild(toggle);
}
function elBtn(label, m, currentMode) {
var btn = document.createElement('button');
btn.className = 'theme-mode-btn' + (m === currentMode ? ' active' : '');
btn.textContent = label;
btn.addEventListener('click', function() {
applyTheme(getCurrentTheme(), m);
renderCards();
AVAILABLE_MODES.forEach(function(m) {
var btn = document.createElement('button');
btn.className = 'theme-mode-btn' + (m === mode ? ' active' : '');
btn.textContent = (modeLabels[m] || function() { return m; })();
btn.addEventListener('click', function() {
applyTheme(getCurrentTheme(), m);
renderCards();
});
toggle.appendChild(btn);
});
return btn;
grid.appendChild(toggle);
// Import/Export buttons
var actions = document.createElement('div');
actions.className = 'theme-actions';
var exportBtn = document.createElement('button');
exportBtn.className = 'theme-action-btn';
exportBtn.textContent = t('theme.export_all');
exportBtn.addEventListener('click', function() {
var json = exportAllThemes();
var blob = new Blob([json], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url; a.download = 'obsigate-themes.json'; a.click();
URL.revokeObjectURL(url);
});
var importBtn = document.createElement('button');
importBtn.className = 'theme-action-btn';
importBtn.textContent = t('theme.import');
importBtn.addEventListener('click', function() {
var input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.addEventListener('change', function(e) {
var file = e.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function(ev) {
var content = ev.target.result;
// Support single theme or all-themes format
try {
var data = JSON.parse(content);
if (data.themes) {
// Multi-theme export: import each
var count = 0;
Object.keys(data.themes).forEach(function(k) {
var res = importTheme(JSON.stringify(data.themes[k]));
if (res.ok) count++;
});
alert(t('theme.import_success', { count: count }));
} else {
var res = importTheme(content);
if (res.ok) {
alert(t('theme.import_success', { count: 1 }));
} else {
alert(t('theme.import_error') + ': ' + res.error);
}
}
} catch(ex) {
alert(t('theme.import_error') + ': ' + ex.message);
}
renderCards();
};
reader.readAsText(file);
});
input.click();
});
actions.appendChild(exportBtn);
actions.appendChild(importBtn);
grid.appendChild(actions);
}
renderCards();