OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

mfa_setup.php

28 KB
<?php $this->layout('layouts/main', ['page_title' => $page_title, 'current_route' => $current_route ?? '', 'full_width' => true]); ?>
<style>
.mfa-wrap { display:flex; flex-direction:column; }
.mfa-body { padding:1.5rem 1rem 3rem; background:var(--color-bg-alt); flex:1; }

.mfa-qr-box {
    display:inline-flex; align-items:center; justify-content:center;
    background:var(--color-bg); border:1px solid rgba(0,0,0,.125); border-radius:.25rem;
    padding:12px; box-shadow:0 0 1px rgba(0,0,0,.125),0 1px 3px rgba(0,0,0,.2);
    margin-bottom:1rem;
}
.mfa-qr-box img { display:block; border-radius:4px; }

.mfa-secret-box {
    display:flex; align-items:center; gap:.5rem;
    background:var(--color-gray-50); border:1px solid var(--color-gray-300); border-radius:.25rem;
    padding:.5rem .75rem; margin-bottom:1rem;
}
.mfa-secret-code {
    flex:1; font-family:monospace; font-size:.9rem; letter-spacing:.18em;
    word-break:break-all; color:var(--color-gray-900); cursor:pointer; user-select:all;
}
.mfa-copy-msg { font-size:.75rem; color:var(--bs-success); display:none; margin-top:.25rem; }

.mfa-step-num {
    display:inline-flex; align-items:center; justify-content:center;
    width:22px; height:22px; border-radius:50%;
    font-size:.75rem; font-weight:700; flex-shrink:0;
}

/* WebAuthn key list */
.wa-key-row {
    display:flex; align-items:center; gap:.75rem;
    padding:.65rem 1rem; border-bottom:1px solid var(--gray-100);
}
.wa-key-row:last-child { border-bottom:none; }
.wa-key-icon { font-size:1.3rem; flex-shrink:0; }
.wa-key-info { flex:1; min-width:0; }
.wa-key-name { font-weight:600; font-size:.875rem; color:var(--color-gray-900); }
.wa-key-meta { font-size:.75rem; color:var(--gray-400); margin-top:1px; }

@media(max-width:768px) { .mfa-body { padding:1.25rem 1rem 2rem; } }
</style>

<div class="mfa-wrap">
    <div class="content-header">
        <div>
            <h1><?= $this->te('Zwei-Faktor-Authentifizierung') ?></h1>
            <ol class="breadcrumb">
                <li class="breadcrumb-item"><a href="/dashboard"><?= $this->te('Dashboard') ?></a></li>
                <li class="breadcrumb-item"><a href="/account"><?= $this->te('Konto') ?></a></li>
                <li class="breadcrumb-item active"><?= $this->te('MFA') ?></li>
            </ol>
        </div>
    </div>

    <div class="mfa-body">
    <div class="row">

        <!-- ══════════════════════════════════════════════
             SPALTE 1: Authenticator-App (TOTP)
        ══════════════════════════════════════════════ -->
        <div class="col-lg-5 col-md-6">

            <?php if ($totp_enabled): ?>
            <!-- TOTP aktiv -->
            <div class="card card-outline card-success">
                <div class="card-header">
                    <h3 class="card-title">
                        <i class="fas fa-mobile-alt text-success mr-2"></i><?= $this->te('Authenticator-App') ?>
                    </h3>
                    <div class="card-tools">
                        <span class="badge badge-success"><?= $this->te('Aktiv') ?></span>
                    </div>
                </div>
                <div class="card-body">
                    <div class="callout callout-success mb-3">
                        <?= $this->te('Dein Konto ist mit einer Authenticator-App (TOTP) geschützt.') ?>
                    </div>
                    <p class="text-muted mb-2" style="font-size:.875rem;">
                        <?= $this->te('Gib zur Bestätigung den aktuellen Code aus deiner App ein.') ?>
                    </p>
                    <form method="POST" action="/account/mfa/disable">
                        <input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
                        <div class="input-group">
                            <input type="text" name="code" class="form-control"
                                   inputmode="numeric" pattern="\d{6}" maxlength="6"
                                   placeholder="<?= $this->te('6-stelliger Code') ?>" required
                                   autocomplete="one-time-code"
                                   style="font-size:1.1rem;letter-spacing:.2em;text-align:center;font-weight:600;">
                            <div class="input-group-append">
                                <button type="submit" class="btn btn-outline-danger"><?= $this->te('Deaktivieren') ?></button>
                            </div>
                        </div>
                    </form>
                </div>
            </div>

            <?php else: ?>
            <!-- TOTP einrichten -->
            <div class="card card-outline card-primary">
                <div class="card-header">
                    <h3 class="card-title">
                        <i class="fas fa-mobile-alt text-primary mr-2"></i><?= $this->te('Authenticator-App') ?>
                    </h3>
                    <div class="card-tools">
                        <span class="badge badge-secondary"><?= $this->te('Nicht aktiv') ?></span>
                    </div>
                </div>
                <div class="card-body">
                    <p class="text-muted mb-3" style="font-size:.875rem;">
                        <?= $this->te('Scanne den QR-Code mit deiner Authenticator-App (Google Authenticator, Authy, Bitwarden, …).') ?>
                    </p>

                    <?php if (!empty($otp_url)): ?>
                    <div class="text-center">
                        <div class="mfa-qr-box">
                            <!-- Der QR-Code wird lokal im Browser erzeugt; das
                                 Einmalpasswort-Geheimnis verlässt den Server nicht. -->
                            <canvas id="mfaQrCanvas" width="200" height="200" role="img" aria-label="<?= $this->te('QR-Code zur Einrichtung der Authenticator-App') ?>"></canvas>
                            <p id="mfaQrFallback" class="text-muted" style="display:none;font-size:.8rem;margin:.5rem 0 0;">
                                <?= $this->te('QR-Code konnte nicht erzeugt werden – bitte den manuellen Schlüssel verwenden.') ?>
                            </p>
                        </div>
                    </div>
                    <script src="/assets/js/qrcode.min.js"></script>
                    <script>
                    (function () {
                        var canvas = document.getElementById('mfaQrCanvas');
                        var fallback = document.getElementById('mfaQrFallback');
                        function failed() {
                            if (canvas) { canvas.style.display = 'none'; }
                            if (fallback) { fallback.style.display = 'block'; }
                        }
                        if (!canvas || !window.QRCode) { failed(); return; }
                        window.QRCode.toCanvas(
                            canvas,
                            <?= json_encode($otp_url, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>,
                            { width: 200, margin: 0, color: { dark: '#000000', light: '#ffffff' } },
                            function (err) { if (err) { failed(); } }
                        );
                    })();
                    </script>
                    <?php endif; ?>

                    <div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--gray-400);margin-bottom:.35rem;">
                        <?= $this->te('Manueller Schlüssel') ?>
                    </div>
                    <div class="mfa-secret-box" onclick="mfaCopySecret()" title="<?= $this->te('Klicken zum Kopieren') ?>">
                        <code class="mfa-secret-code"><?= chunk_split(htmlspecialchars($temp_secret ?? ''), 4, ' ') ?></code>
                        <button type="button" class="btn btn-xs btn-outline-secondary" aria-label="<?= $this->te('Kopieren') ?>" style="line-height:1;padding:.2rem .4rem;">
                            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
                        </button>
                    </div>
                    <div id="mfaCopyMsg" class="mfa-copy-msg"><i class="fas fa-check mr-1"></i><?= $this->te('Kopiert!') ?></div>

                    <hr class="mt-3 mb-3">
                    <p class="text-muted mb-2" style="font-size:.875rem;">
                        <?= $this->t('<span class="mfa-step-num bg-success text-white mr-1" style="vertical-align:middle;">✓</span> Code aus der App eingeben, um die Einrichtung abzuschließen:') ?>
                    </p>
                    <form method="POST" action="/account/mfa/enable" id="mfaEnableForm">
                        <input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
                        <div class="input-group">
                            <input type="text" name="code" id="mfaEnableCode" class="form-control"
                                   inputmode="numeric" pattern="\d{6}" maxlength="6"
                                   placeholder="<?= $this->te('6-stelliger Code') ?>" required
                                   autocomplete="one-time-code"
                                   style="font-size:1.2rem;letter-spacing:.25em;text-align:center;font-weight:700;">
                            <div class="input-group-append">
                                <button type="submit" class="btn btn-success">
                                    <i class="fas fa-check mr-1"></i><?= $this->te('Aktivieren') ?>
                                </button>
                            </div>
                        </div>
                    </form>
                </div>
                <div class="card-footer text-muted" style="font-size:.78rem;">
                    <?= $this->te('Kompatibel mit Google Authenticator, Authy, Bitwarden &amp; allen RFC&nbsp;6238-Apps') ?>
                </div>
            </div>
            <?php endif; ?>

        </div><!-- /col TOTP -->

        <!-- ══════════════════════════════════════════════
             SPALTE 2: Sicherheitsschlüssel (WebAuthn/YubiKey)
        ══════════════════════════════════════════════ -->
        <div class="col-lg-5 col-md-6">
            <?php $hasKeys = !empty($webauthn_keys); ?>
            <div class="card card-outline <?= $hasKeys ? 'card-success' : 'card-secondary' ?>">
                <div class="card-header">
                    <h3 class="card-title">
                        <i class="fas fa-key <?= $hasKeys ? 'text-success' : 'text-secondary' ?> mr-2"></i><?= $this->te('Sicherheitsschlüssel') ?>
                    </h3>
                    <div class="card-tools">
                        <?php if ($hasKeys): ?>
                        <span class="badge badge-success"><?= $this->te('{anzahl} registriert', ['anzahl' => count($webauthn_keys)]) ?></span>
                        <?php else: ?>
                        <span class="badge badge-secondary"><?= $this->te('Nicht eingerichtet') ?></span>
                        <?php endif; ?>
                    </div>
                </div>

                <?php if ($hasKeys): ?>
                <!-- List of registered keys -->
                <div style="border-bottom:1px solid var(--gray-100);">
                    <?php foreach ($webauthn_keys as $key): ?>
                    <div class="wa-key-row">
                        <div class="wa-key-icon">🔑</div>
                        <div class="wa-key-info">
                            <div class="wa-key-name"><?= htmlspecialchars($key['device_name']) ?></div>
                            <div class="wa-key-meta">
                                <?= $this->te('Registriert: {datum}', ['datum' => date('d.m.Y', strtotime($key['created_at']))]) ?>
                                <?php if ($key['last_used_at']): ?>
                                · <?= $this->te('Zuletzt: {datum}', ['datum' => date('d.m.Y', strtotime($key['last_used_at']))]) ?>
                                <?php endif; ?>
                            </div>
                        </div>
                        <form method="POST" action="/account/mfa/webauthn/delete/<?= $key['id'] ?>"
                              onsubmit="return confirm('Schlüssel \"<?= htmlspecialchars(addslashes($key['device_name'])) ?>\" wirklich entfernen?')">
                            <input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
                            <button type="submit" class="btn btn-xs btn-outline-danger" title="<?= $this->te('Entfernen') ?>">
                                <i class="fas fa-trash"></i>
                            </button>
                        </form>
                    </div>
                    <?php endforeach; ?>
                </div>
                <?php else: ?>
                <div class="card-body pb-2">
                    <p class="text-muted mb-0" style="font-size:.875rem;">
                        <?= $this->te('Melde dich mit einem Klick an — kein Code-Abtippen. Kompatibel mit YubiKey, FIDO2-Sticks und anderen Hardware-Tokens.') ?>
                    </p>
                </div>
                <?php endif; ?>

                <div class="card-body pt-2">
                    <?php if ($hasKeys): ?>
                    <p class="text-muted mb-2" style="font-size:.8rem;">
                        <i class="fas fa-info-circle mr-1"></i>
                        <?= $this->te('Du kannst beliebig viele Schlüssel registrieren — z.&nbsp;B. einen Backup-Key für den Notfall.') ?>
                    </p>
                    <?php endif; ?>

                    <!-- Register new key -->
                    <div id="waRegStatus" style="display:none;" class="callout callout-info mb-3" style="font-size:.85rem;"></div>
                    <div id="waRegError"  style="display:none;" class="callout callout-danger mb-3" style="font-size:.85rem;"></div>

                    <div class="input-group input-group-sm mb-2">
                        <input type="text" id="waKeyName" class="form-control"
                               placeholder="<?= $hasKeys ? 'Name des Backup-Schlüssels (z.B. YubiKey Zuhause)' : 'Name des Schlüssels (z.B. YubiKey Büro)' ?>"
                               value="<?= $hasKeys ? 'YubiKey Backup' : 'YubiKey' ?>" maxlength="60">
                        <div class="input-group-append">
                            <button id="waRegBtn" type="button" class="btn btn-primary" onclick="waRegisterStart()">
                                <i class="fas fa-plus mr-1"></i><?= $hasKeys ? 'Backup-Schlüssel hinzufügen' : 'Schlüssel registrieren' ?>
                            </button>
                        </div>
                    </div>
                    <small class="text-muted" style="font-size:.75rem;">
                        <?= $this->te('Stecke deinen YubiKey ein und klicke dann auf den Button.') ?>
                    </small>
                </div>

                <div class="card-footer text-muted" style="font-size:.78rem;">
                    <?= $this->te('Unterstützt YubiKey, alle FIDO2/WebAuthn-kompatiblen Sicherheitsschlüssel') ?>
                </div>
            </div>
        </div><!-- /col WebAuthn -->

    </div><!-- /row -->

    <!-- ══════════════════════════════════════════════
         Aktive Sitzungen
    ══════════════════════════════════════════════ -->
    <div class="row">
        <div class="col-lg-10">
            <div class="card card-outline card-secondary">
                <div class="card-header">
                    <h3 class="card-title">
                        <i class="fas fa-desktop text-secondary mr-2"></i><?= $this->te('Aktive Sitzungen') ?>
                    </h3>
                    <div class="card-tools">
                        <?php if (count($sessions ?? []) > 1): ?>
                        <form method="POST" action="/account/sessions/revoke-others" style="margin:0;"
                              onsubmit="return confirm('Alle anderen Sitzungen abmelden? Auf allen anderen Geräten musst du dich danach neu anmelden.')">
                            <input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
                            <button type="submit" class="btn btn-xs btn-outline-danger">
                                <i class="fas fa-sign-out-alt mr-1"></i><?= $this->te('Überall sonst abmelden') ?>
                            </button>
                        </form>
                        <?php endif; ?>
                    </div>
                </div>
                <div class="card-body p-0">
                    <?php if (empty($sessions)): ?>
                    <div class="p-3 text-muted" style="font-size:.875rem;"><?= $this->te('Keine aktiven Sitzungen gefunden.') ?></div>
                    <?php else: ?>
                    <div style="overflow-x:auto;">
                        <table class="data-table">
                            <thead>
                                <tr>
                                    <th scope="col"><?= $this->te('Gerät') ?></th>
                                    <th scope="col"><?= $this->te('IP-Adresse') ?></th>
                                    <th scope="col"><?= $this->te('Angemeldet') ?></th>
                                    <th scope="col"><?= $this->te('Letzte Aktivität') ?></th>
                                </tr>
                            </thead>
                            <tbody>
                                <?php foreach ($sessions as $s): ?>
                                <tr>
                                    <td>
                                        <i class="fas fa-desktop text-muted mr-1"></i>
                                        <?= htmlspecialchars($s['device'] ?? 'Unbekanntes Gerät') ?>
                                        <?php if (!empty($s['is_current'])): ?>
                                        <span class="badge badge-success ml-1"><?= $this->te('Diese Sitzung') ?></span>
                                        <?php endif; ?>
                                    </td>
                                    <td><code style="font-size:.8rem;"><?= htmlspecialchars($s['ip_address'] ?? '') ?></code></td>
                                    <td class="text-muted"><?= !empty($s['created_at']) ? date('d.m.Y H:i', strtotime($s['created_at'])) : '–' ?></td>
                                    <td class="text-muted"><?= !empty($s['last_activity']) ? date('d.m.Y H:i', strtotime($s['last_activity'])) : '–' ?></td>
                                </tr>
                                <?php endforeach; ?>
                            </tbody>
                        </table>
                    </div>
                    <?php endif; ?>
                </div>
                <div class="card-footer text-muted" style="font-size:.78rem;">
                    <i class="fas fa-info-circle mr-1"></i>
                    <?= $this->te('Meldest du dich von einem neuen Gerät an, erhältst du automatisch eine Benachrichtigung. Kommt dir eine Sitzung unbekannt vor, melde alle anderen ab und ändere dein Passwort.') ?>
                </div>
            </div>
        </div>
    </div><!-- /row Sitzungen -->
    </div><!-- /.mfa-body -->
</div><!-- /.mfa-wrap -->

<!-- Backup-Codes-Modal -->
<div id="backupCodesModal" class="modal" hidden role="dialog" aria-modal="true" aria-labelledby="backupCodesTitle">
    <div class="modal-backdrop"></div>
    <div class="modal-dialog" style="max-width:460px">
        <div class="modal-header" style="background:var(--green-700);color:var(--color-on-accent);">
            <h3 id="backupCodesTitle" style="color:var(--color-on-accent);"><?= $this->te('&#128274; Backup-Codes') ?></h3>
        </div>
        <div class="modal-body">
            <div class="callout callout-warning" style="margin-bottom:1rem;">
                <?= $this->t('<strong>&#9888; Wichtig – nur einmal sichtbar!</strong><br> Diese 10 Backup-Codes werden dir nur jetzt angezeigt. Speichere sie an einem sicheren Ort (Passwort-Manager, ausgedruckt in einem verschlossenen Umschlag o.ä.).<br> Jeder Code kann nur <strong>einmal</strong> zur Anmeldung verwendet werden.') ?>
            </div>

            <ul id="backupCodesList" style="
                list-style:none;margin:0;padding:0;
                display:grid;grid-template-columns:1fr 1fr;gap:.375rem .75rem;
                font-family:monospace;font-size:1rem;font-weight:600;letter-spacing:.05em;
                background:#f8f9fa;border:1px solid #dee2e6;border-radius:.375rem;padding:.75rem 1rem;
            "></ul>

            <div style="margin-top:1rem;display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;">
                <button type="button" class="btn btn-sm btn-outline-secondary" onclick="copyBackupCodes()">
                    <?= $this->te('&#128203; Kopieren') ?>
                </button>
                <span id="backupCopyMsg" style="display:none;font-size:.8rem;color:var(--green-700);"><?= $this->te('&#10003; Kopiert!') ?></span>
                <small style="color:var(--color-secondary);margin-left:auto;font-size:.75rem;"><?= $this->te('10 Codes &bull; je einmalig verwendbar') ?></small>
            </div>

            <div style="margin-top:1.25rem;border-top:1px solid var(--color-gray-300);padding-top:.875rem;">
                <button type="button" class="btn btn-success btn-block"
                        onclick="document.getElementById('backupCodesModal').setAttribute('hidden',''); window.location.reload();">
                    <?= $this->te('Ich habe die Codes gespeichert &#10003;') ?>
                </button>
            </div>
        </div>
    </div>
</div>

<script>
<?php if (!$totp_enabled && !empty($temp_secret)): ?>
function mfaCopySecret() {
    var secret = <?= json_encode(str_replace(' ', '', $temp_secret ?? '')) ?>;
    var msg = document.getElementById('mfaCopyMsg');
    if (navigator.clipboard && navigator.clipboard.writeText) {
        navigator.clipboard.writeText(secret).then(function() {
            msg.style.display = 'block';
            setTimeout(function() { msg.style.display = 'none'; }, 2000);
        });
    } else {
        var ta = document.createElement('textarea');
        ta.value = secret; ta.style.cssText = 'position:fixed;opacity:0;';
        document.body.appendChild(ta); ta.select();
        try { document.execCommand('copy'); } catch(e) {}
        document.body.removeChild(ta);
        msg.style.display = 'block';
        setTimeout(function() { msg.style.display = 'none'; }, 2000);
    }
}
var codeEl = document.getElementById('mfaEnableCode');
if (codeEl) {
    codeEl.addEventListener('input', function() {
        var clean = this.value.replace(/\D/g, '');
        this.value = clean;
        if (clean.length === 6) document.getElementById('mfaEnableForm').submit();
    });
}
<?php endif; ?>

// ── WebAuthn Registration ──────────────────────────────────────
function b64uToBuffer(b64u) {
    var b64 = b64u.replace(/-/g,'+').replace(/_/g,'/');
    while (b64.length % 4) b64 += '=';
    var bin = atob(b64), buf = new Uint8Array(bin.length);
    for (var i=0; i<bin.length; i++) buf[i] = bin.charCodeAt(i);
    return buf.buffer;
}
function bufToB64u(buf) {
    var bytes = new Uint8Array(buf), bin = '';
    bytes.forEach(function(b){ bin += String.fromCharCode(b); });
    return btoa(bin).replace(/\+/g,'-').replace(/\//g,'_').replace(/=/g,'');
}

async function waRegisterStart() {
    var btn    = document.getElementById('waRegBtn');
    var status = document.getElementById('waRegStatus');
    var errEl  = document.getElementById('waRegError');
    var name   = (document.getElementById('waKeyName').value.trim()) || 'Sicherheitsschlüssel';
    var csrf   = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';

    btn.disabled = true;
    btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Warte auf Schlüssel…';
    status.style.display = 'block';
    status.textContent   = 'Stecke deinen Schlüssel ein und berühre ihn, wenn er blinkt.';
    errEl.style.display  = 'none';

    try {
        // 1. Get challenge
        var beginResp = await fetch('/account/mfa/webauthn/register/begin', {
            method: 'POST',
            headers: {'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest','X-CSRF-Token':csrf},
            body: '{}'
        });
        if (!beginResp.ok) { var e=await beginResp.json(); throw new Error(e.error||'Server-Fehler'); }
        var createArgs = await beginResp.json();

        // 2. Decode binary fields
        createArgs.publicKey.challenge = b64uToBuffer(createArgs.publicKey.challenge);
        createArgs.publicKey.user.id   = b64uToBuffer(createArgs.publicKey.user.id);
        if (createArgs.publicKey.excludeCredentials) {
            createArgs.publicKey.excludeCredentials = createArgs.publicKey.excludeCredentials.map(function(c){
                return Object.assign({}, c, {id: b64uToBuffer(c.id)});
            });
        }

        // 3. Prompt browser / key
        var cred = await navigator.credentials.create(createArgs);

        // 4. Send to server
        var completeResp = await fetch('/account/mfa/webauthn/register/complete', {
            method: 'POST',
            headers: {'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest','X-CSRF-Token':csrf},
            body: JSON.stringify({
                deviceName:        name,
                clientDataJSON:    bufToB64u(cred.response.clientDataJSON),
                attestationObject: bufToB64u(cred.response.attestationObject)
            })
        });
        var result = await completeResp.json();
        if (result.ok) {
            if (result.backup_codes && result.backup_codes.length) {
                showBackupCodesModal(result.backup_codes);
            } else {
                status.textContent = '✅ Schlüssel erfolgreich hinzugefügt! Seite wird neu geladen…';
                setTimeout(function(){ window.location.reload(); }, 1200);
            }
        } else {
            throw new Error(result.error || 'Registrierung fehlgeschlagen.');
        }
    } catch(err) {
        status.style.display = 'none';
        errEl.style.display  = 'block';
        errEl.textContent = err.name === 'NotAllowedError'
            ? 'Abgebrochen oder Zeitüberschreitung. Bitte erneut versuchen.'
            : (err.message || 'Ein Fehler ist aufgetreten.');
        btn.disabled = false;
        btn.innerHTML = '<i class="fas fa-plus mr-1"></i>Schlüssel registrieren';
    }
}

function showBackupCodesModal(codes) {
    var modal = document.getElementById('backupCodesModal');
    var list  = document.getElementById('backupCodesList');
    if (!modal || !list) { window.location.reload(); return; }
    list.innerHTML = '';
    codes.forEach(function(c) {
        var li = document.createElement('li');
        li.textContent = c;
        list.appendChild(li);
    });
    modal.removeAttribute('hidden');
}

function copyBackupCodes() {
    var items = document.querySelectorAll('#backupCodesList li');
    var text = Array.from(items).map(function(li){ return li.textContent; }).join('\n');
    if (navigator.clipboard && navigator.clipboard.writeText) {
        navigator.clipboard.writeText(text).then(function() {
            document.getElementById('backupCopyMsg').style.display = 'inline';
            setTimeout(function() { document.getElementById('backupCopyMsg').style.display = 'none'; }, 2000);
        });
    }
}
</script>