feat: add TOTP 2FA with QR code and manual secret entry
This commit is contained in:
+148
-12
@@ -8,6 +8,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||
<script src="/e2ee.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--blue: #2563eb; --blue-d: #1d4ed8;
|
||||
@@ -1421,6 +1422,52 @@ footer {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" id="2fa-section">
|
||||
<div class="settings-label">Zwei-Faktor-Authentifizierung (2FA)</div>
|
||||
<div id="2fa-status-row" style="font-size:13px;color:var(--text-muted);margin-bottom:8px">Wird geladen…</div>
|
||||
<!-- Setup flow -->
|
||||
<div id="2fa-setup-area" style="display:none">
|
||||
<div style="margin-bottom:10px;font-size:13px;color:var(--text-2)">Scanne den QR-Code mit deiner Authenticator-App (z.B. Google Authenticator, Authy).</div>
|
||||
<img id="2fa-qr" style="width:180px;height:180px;border-radius:8px;border:1px solid var(--border);display:block;margin-bottom:8px" alt="QR Code">
|
||||
<details style="margin-bottom:10px;font-size:12px">
|
||||
<summary style="cursor:pointer;color:var(--text-muted);user-select:none">Kein Kamera? Manuell eingeben</summary>
|
||||
<div style="margin-top:6px;padding:8px;background:var(--n-100);border-radius:6px;border:1px solid var(--border)">
|
||||
<div style="color:var(--text-muted);margin-bottom:4px">Geheimschlüssel (Base32):</div>
|
||||
<code id="2fa-secret" style="font-size:13px;word-break:break-all;color:var(--text);letter-spacing:.05em"></code>
|
||||
<div style="color:var(--text-subtle);margin-top:4px;font-size:11px">In App: Konto manuell hinzufügen → TOTP → diesen Schlüssel eingeben</div>
|
||||
</div>
|
||||
</details>
|
||||
<div class="settings-fields">
|
||||
<input type="text" id="2fa-confirm-code" placeholder="6-stelliger Code zur Bestätigung" maxlength="6" inputmode="numeric" autocomplete="one-time-code">
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<button class="btn-save" style="align-self:flex-start" onclick="confirm2FA()">Bestätigen & aktivieren</button>
|
||||
<button class="btn-cancel" style="align-self:flex-start" onclick="cancel2FASetup()">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Disable flow -->
|
||||
<div id="2fa-disable-area" style="display:none">
|
||||
<div class="settings-fields">
|
||||
<input type="password" id="2fa-disable-pw" placeholder="Aktuelles Passwort">
|
||||
<input type="text" id="2fa-disable-code" placeholder="6-stelliger 2FA-Code" maxlength="6" inputmode="numeric" autocomplete="one-time-code">
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn-danger" style="align-self:flex-start" onclick="disable2FA()">2FA deaktivieren</button>
|
||||
<button class="btn-cancel" style="align-self:flex-start" onclick="cancel2FADisable()">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Idle buttons -->
|
||||
<div id="2fa-idle-area" style="display:none">
|
||||
<button class="btn-save" style="align-self:flex-start" onclick="setup2FA()">2FA einrichten</button>
|
||||
</div>
|
||||
<div id="2fa-enabled-area" style="display:none">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
<span style="font-size:13px;color:var(--green);font-weight:600">✓ 2FA ist aktiv</span>
|
||||
</div>
|
||||
<button class="btn-danger" style="font-size:12px;padding:5px 12px" onclick="showDisable2FA()">2FA deaktivieren</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="danger-zone">
|
||||
<p>Account und alle gespeicherten Daten werden <strong>unwiderruflich gelöscht</strong>.</p>
|
||||
@@ -1572,7 +1619,7 @@ function loginUI(username,id,role,subject){
|
||||
</div>`;
|
||||
|
||||
loadAll();
|
||||
initChat(username);
|
||||
initChat(username, id);
|
||||
}
|
||||
|
||||
function toggleDropdown(el){
|
||||
@@ -1870,9 +1917,92 @@ async function saveModal(){
|
||||
function openSettings(){
|
||||
document.getElementById('settings-overlay').style.display='flex';
|
||||
document.getElementById('user-dropdown')?.classList.remove('open');
|
||||
load2FAStatus();
|
||||
}
|
||||
function closeSettings(){document.getElementById('settings-overlay').style.display='none';}
|
||||
|
||||
async function load2FAStatus(){
|
||||
const statusRow=document.getElementById('2fa-status-row');
|
||||
document.getElementById('2fa-idle-area').style.display='none';
|
||||
document.getElementById('2fa-enabled-area').style.display='none';
|
||||
document.getElementById('2fa-setup-area').style.display='none';
|
||||
document.getElementById('2fa-disable-area').style.display='none';
|
||||
try {
|
||||
const r=await api('GET','2fa/status');
|
||||
statusRow.textContent='';
|
||||
if(r.enabled){
|
||||
document.getElementById('2fa-enabled-area').style.display='';
|
||||
} else {
|
||||
document.getElementById('2fa-idle-area').style.display='';
|
||||
}
|
||||
} catch(e) {
|
||||
statusRow.textContent='Fehler beim Laden.';
|
||||
}
|
||||
}
|
||||
|
||||
async function setup2FA(){
|
||||
document.getElementById('2fa-idle-area').style.display='none';
|
||||
document.getElementById('2fa-status-row').textContent='QR-Code wird generiert…';
|
||||
try {
|
||||
const r=await api('POST','2fa/setup');
|
||||
document.getElementById('2fa-status-row').textContent='';
|
||||
if(r.error){toast(r.error,'error');document.getElementById('2fa-idle-area').style.display='';return;}
|
||||
document.getElementById('2fa-qr').src=r.qr;
|
||||
document.getElementById('2fa-secret').textContent=r.secret;
|
||||
document.getElementById('2fa-confirm-code').value='';
|
||||
document.getElementById('2fa-setup-area').style.display='';
|
||||
document.getElementById('2fa-confirm-code').focus();
|
||||
} catch(e) {
|
||||
document.getElementById('2fa-status-row').textContent='';
|
||||
toast('Fehler beim Generieren des QR-Codes','error');
|
||||
document.getElementById('2fa-idle-area').style.display='';
|
||||
}
|
||||
}
|
||||
|
||||
function cancel2FASetup(){
|
||||
document.getElementById('2fa-setup-area').style.display='none';
|
||||
document.getElementById('2fa-idle-area').style.display='';
|
||||
}
|
||||
|
||||
async function confirm2FA(){
|
||||
const code=document.getElementById('2fa-confirm-code').value.trim();
|
||||
if(!code){toast('Code eingeben','error');return;}
|
||||
const r=await api('POST','2fa/confirm',{token:code});
|
||||
if(r.ok){
|
||||
toast('2FA aktiviert ✓','success');
|
||||
document.getElementById('2fa-setup-area').style.display='none';
|
||||
document.getElementById('2fa-enabled-area').style.display='';
|
||||
} else {
|
||||
toast(r.error,'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showDisable2FA(){
|
||||
document.getElementById('2fa-enabled-area').style.display='none';
|
||||
document.getElementById('2fa-disable-pw').value='';
|
||||
document.getElementById('2fa-disable-code').value='';
|
||||
document.getElementById('2fa-disable-area').style.display='';
|
||||
}
|
||||
|
||||
function cancel2FADisable(){
|
||||
document.getElementById('2fa-disable-area').style.display='none';
|
||||
document.getElementById('2fa-enabled-area').style.display='';
|
||||
}
|
||||
|
||||
async function disable2FA(){
|
||||
const pw=document.getElementById('2fa-disable-pw').value;
|
||||
const code=document.getElementById('2fa-disable-code').value.trim();
|
||||
if(!pw||!code){toast('Passwort und Code erforderlich','error');return;}
|
||||
const r=await api('POST','2fa/disable',{password:pw,token:code});
|
||||
if(r.ok){
|
||||
toast('2FA deaktiviert','success');
|
||||
document.getElementById('2fa-disable-area').style.display='none';
|
||||
document.getElementById('2fa-idle-area').style.display='';
|
||||
} else {
|
||||
toast(r.error,'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword(){
|
||||
const cp=document.getElementById('pw-current').value;
|
||||
const np=document.getElementById('pw-new').value;
|
||||
@@ -1900,18 +2030,19 @@ function chatFmtTime(ts) {
|
||||
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function renderChatMsg(m, append) {
|
||||
async function renderChatMsg(m, append) {
|
||||
const el = document.getElementById('chat-msgs');
|
||||
const isOwn = m.username === chatMyUsername;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg';
|
||||
div.dataset.id = m.id;
|
||||
const displayContent = await E2EE.decrypt(m.content);
|
||||
div.innerHTML = `<div class="chat-msg-meta">
|
||||
<span class="chat-msg-user${isOwn ? ' own' : ''}">${esc(m.username)}</span>
|
||||
<span class="chat-msg-time">${chatFmtTime(m.created_at)}</span>
|
||||
<button class="chat-msg-del" onclick="delChatMsg(${m.id})" title="Löschen">✕</button>
|
||||
</div>
|
||||
<div class="chat-msg-body">${esc(m.content)}</div>`;
|
||||
<div class="chat-msg-body">${esc(displayContent)}</div>`;
|
||||
if (append) {
|
||||
el.appendChild(div);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
@@ -1924,17 +2055,17 @@ async function loadChat() {
|
||||
const msgs = await api('GET', 'chat');
|
||||
const el = document.getElementById('chat-msgs');
|
||||
el.innerHTML = '';
|
||||
msgs.forEach(m => renderChatMsg(m, true));
|
||||
for (const m of msgs) await renderChatMsg(m, true);
|
||||
if (msgs.length) chatLastId = msgs[msgs.length - 1].id;
|
||||
}
|
||||
|
||||
async function pollChat() {
|
||||
try {
|
||||
const msgs = await api('GET', 'chat?after=' + chatLastId);
|
||||
msgs.forEach(m => {
|
||||
renderChatMsg(m, true);
|
||||
for (const m of msgs) {
|
||||
await renderChatMsg(m, true);
|
||||
chatLastId = Math.max(chatLastId, m.id);
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
chatPollTimer = setTimeout(pollChat, 3000);
|
||||
}
|
||||
@@ -1944,9 +2075,12 @@ async function sendChatMsg() {
|
||||
const content = inp.value.trim();
|
||||
if (!content) return;
|
||||
inp.value = '';
|
||||
const r = await api('POST', 'chat', { content });
|
||||
if (r.error) { toast(r.error, 'error'); return; }
|
||||
renderChatMsg(r, true);
|
||||
let ciphertext;
|
||||
try { ciphertext = await E2EE.encrypt(content); }
|
||||
catch { toast('Verschlüsselung fehlgeschlagen', 'error'); inp.value = content; return; }
|
||||
const r = await api('POST', 'chat', { content: ciphertext });
|
||||
if (r.error) { toast(r.error, 'error'); inp.value = content; return; }
|
||||
await renderChatMsg(r, true);
|
||||
chatLastId = Math.max(chatLastId, r.id);
|
||||
}
|
||||
|
||||
@@ -1957,9 +2091,11 @@ async function delChatMsg(id) {
|
||||
document.querySelector(`.chat-msg[data-id="${id}"]`)?.remove();
|
||||
}
|
||||
|
||||
function initChat(username) {
|
||||
async function initChat(username, userId) {
|
||||
chatMyUsername = username;
|
||||
loadChat().then(() => pollChat());
|
||||
await E2EE.init(userId);
|
||||
await loadChat();
|
||||
pollChat();
|
||||
document.getElementById('chat-input').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChatMsg(); }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user