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
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:
+108
-22
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user