/bewerbung: Tabs Unterlagen/E-Mail + Admin-Nutzerdetailseite

- Bewerbungsdetail: Toggle zwischen Bewerbungsunterlagen und E-Mail-Korrespondenz
  (hash-basiert), weniger Scroll auf der Seite.
- Admin: Klick auf einen Nutzer öffnet neue Verwaltungsseite
  /admin/users/:id (Passwort, Anmelden als, Jobsuche, Admin-Status, Löschen).
- Admin-Tabelle: Löschen und Jobsuche aus den Aktionen entfernt
  (jetzt auf der Nutzerdetailseite).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-20 08:37:54 +02:00
co-authored by Claude
parent ada69eb482
commit fa07dbf90a
4 changed files with 296 additions and 20 deletions
+47
View File
@@ -4284,6 +4284,53 @@ initializeDatabase().then(async () => {
}
});
// Einzelnen Benutzer verwalten (Detailansicht von „Bestehende Benutzer").
app.get('/admin/users/:id', requireAdmin, async (req, res) => {
try {
const id = Number(req.params.id);
const user = await dbGet('SELECT id, username, is_admin, created_at FROM users WHERE id = ?', [id]);
if (!user) return res.status(404).send('Benutzer nicht gefunden.');
const anzahl = await dbGet('SELECT COUNT(*) AS c FROM bewerbungen WHERE user_id = ?', [id]);
const bewerbungen = await dbAll(
'SELECT id, firma, stelle, status, datum FROM bewerbungen WHERE user_id = ? ORDER BY date(datum) DESC, created_at DESC LIMIT 10',
[id]
);
const adminCount = await dbGet('SELECT COUNT(*) AS c FROM users WHERE is_admin = 1');
res.render('admin-user', {
user,
currentUserId: req.user.id,
anzahlBewerbungen: anzahl ? anzahl.c : 0,
bewerbungen: bewerbungen || [],
adminCount: adminCount ? adminCount.c : 0,
hideSettings: false,
});
} catch (error) {
console.error('Admin user detail error:', error);
res.status(500).send('Serverfehler');
}
});
// Admin-Status eines Benutzers umschalten. Der letzte Admin darf nicht
// abgesetzt werden (sonst sperrt man sich aus).
app.post('/admin/users/:id/toggle-admin', requireAdmin, async (req, res) => {
try {
const id = Number(req.params.id);
const target = await dbGet('SELECT id, is_admin FROM users WHERE id = ?', [id]);
if (!target) return res.status(404).send('Benutzer nicht gefunden.');
if (target.is_admin) {
const adminCount = await dbGet('SELECT COUNT(*) AS c FROM users WHERE is_admin = 1');
if (adminCount && adminCount.c <= 1) return res.status(400).send('Der letzte Admin darf nicht abgesetzt werden.');
}
const neu = target.is_admin ? 0 : 1;
await dbRun('UPDATE users SET is_admin = ? WHERE id = ?', [neu, id]);
await auditLog(req.user.id, id, `admin_toggle → ${neu ? 'admin' : 'user'}`);
res.redirect('/admin/users/' + id);
} catch (error) {
console.error('Admin toggle-admin error:', error);
res.status(500).send('Serverfehler');
}
});
// --- Impersonation: admin "logs in as" a user --------------------------
// Best practice (Django/Flask-impersonate style): the admin's own session
// keeps its cookie token, but its user_id switches to the target and the
+193
View File
@@ -0,0 +1,193 @@
<!DOCTYPE html>
<html lang="de">
<head>
<%- include('partials/head') %>
</head>
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
<%- include('partials/header') %>
<main class="flex-1 container mx-auto px-4 py-8 max-w-5xl">
<a href="/admin" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Zur Benutzerverwaltung
</a>
<%
const istIch = user.id === currentUserId;
const letzterAdmin = user.is_admin && adminCount <= 1;
%>
<div class="flex flex-wrap items-center gap-4 mb-8">
<span class="grid h-14 w-14 place-items-center rounded-full text-lg font-semibold <%= user.is_admin ? 'bg-blue-600/10 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300' %>">
<%= String(user.username || '?').charAt(0).toUpperCase() %>
</span>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2.5">
<h1 class="text-2xl font-bold text-gray-800 dark:text-white"><%= user.username %></h1>
<% if (user.is_admin) { %>
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">Admin</span>
<% } else { %>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300">Benutzer</span>
<% } %>
<% if (istIch) { %><span class="text-xs text-gray-400">(du)</span><% } %>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
<%= anzahlBewerbungen %> <%= anzahlBewerbungen === 1 ? 'Bewerbung' : 'Bewerbungen' %> ·
angelegt <time data-dt="<%= user.created_at %>"><%= user.created_at %></time>
</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<!-- Verwaltung -->
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">Benutzer verwalten</h2>
<div class="space-y-5">
<!-- Admin-Status -->
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-gray-800 dark:text-gray-100">Admin-Status</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
<% if (letzterAdmin) { %>Letzter Admin kann nicht abgesetzt werden.<% } else { %>Admins können Benutzer verwalten und andere Konten übernehmen.<% } %>
</p>
</div>
<form method="POST" action="/admin/users/<%= user.id %>/toggle-admin" class="inline">
<button type="submit" <%= letzterAdmin ? 'disabled' : '' %>
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed
<%= user.is_admin ? 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600' : 'bg-blue-600 hover:bg-blue-700 text-white' %>">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0a1.724 1.724 0 002.573 1.066c.78-.448 1.707.479 1.26 1.26a1.724 1.724 0 001.065 2.572c.921.3.921 1.603 0 1.902a1.724 1.724 0 00-1.066 2.573c.448.78-.479 1.707-1.26 1.26a1.724 1.724 0 00-2.572 1.065c-.3.921-1.603.921-1.902 0a1.724 1.724 0 00-2.573-1.066c-.78.448-1.707-.479-1.26-1.26a1.724 1.724 0 00-1.065-2.572c-.921-.3-.921-1.603 0-1.902a1.724 1.724 0 001.066-2.573c-.448-.78.479-1.707 1.26-1.26.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
<%= user.is_admin ? 'Zum Benutzer herabstufen' : 'Zum Admin machen' %>
</button>
</form>
</div>
<hr class="border-gray-100 dark:border-gray-700">
<!-- Passwort zurücksetzen -->
<div>
<div class="flex flex-wrap items-center justify-between gap-3 mb-2">
<div>
<p class="text-sm font-medium text-gray-800 dark:text-gray-100">Passwort zurücksetzen</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Alle Sitzungen dieses Benutzers werden ungültig.</p>
</div>
<button type="button" onclick="document.getElementById('resetForm').classList.toggle('hidden')"
class="act-btn act-blue">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path></svg>
<span>Neues Passwort</span>
</button>
</div>
<form id="resetForm" method="POST" action="/admin/users/<%= user.id %>/reset-password" class="hidden flex items-end gap-3 flex-wrap rounded-lg border border-blue-200 bg-blue-50/50 p-4 dark:border-blue-500/20 dark:bg-blue-500/5">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Neues Passwort für <%= user.username %>
<input name="password" type="password" required
class="mt-1.5 block w-full sm:w-72 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-gray-800 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" />
</label>
<button type="submit" class="rounded-lg bg-blue-600 hover:bg-blue-700 transition-colors text-white font-medium px-4 py-2 shadow-sm">Speichern</button>
<button type="button" onclick="document.getElementById('resetForm').classList.toggle('hidden')"
class="rounded-lg px-3 py-2 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">Abbrechen</button>
</form>
</div>
<hr class="border-gray-100 dark:border-gray-700">
<!-- Anmelden als / Suchprofil -->
<div class="flex flex-wrap gap-2">
<% if (!istIch) { %>
<form method="POST" action="/admin/users/<%= user.id %>/impersonate" class="inline"
onsubmit="return confirm('Als „<%= user.username %>“ anmelden? Du siehst dann dessen Konto. Admin-Rechte sind dabei pausiert; über das Banner oben kannst du zurückwechseln.');">
<button type="submit" class="act-btn act-emerald" title="Dieses Konto übernehmen (Impersonation)">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14v7m-4-4h8"></path></svg>
<span>Anmelden als</span>
</button>
</form>
<% } %>
<a href="/jobsuche?user=<%= user.id %>" class="act-btn act-neutral" title="Suchprofil & Zeitplan dieses Benutzers verwalten">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
<span>Jobsuche</span>
</a>
</div>
<% if (!istIch) { %>
<hr class="border-gray-100 dark:border-gray-700">
<!-- Löschen -->
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-rose-700 dark:text-rose-400">Benutzer löschen</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Löscht das Konto inkl. aller Bewerbungen und Dateien. Unumkehrbar.</p>
</div>
<form method="POST" action="/admin/users/<%= user.id %>/delete" class="inline"
onsubmit="return confirm('Benutzer „<%= user.username %>“ inkl. aller Daten wirklich löschen? Das lässt sich nicht rückgängig machen.');">
<button type="submit" class="act-btn act-danger" title="Benutzer inkl. aller Daten löschen">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
<span>Löschen</span>
</button>
</form>
</div>
<% } %>
</div>
</section>
<!-- Letzte Bewerbungen -->
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">Letzte Bewerbungen</h2>
<% if (bewerbungen.length) { %>
<ul class="divide-y divide-gray-100 dark:divide-gray-700">
<% bewerbungen.forEach(function(b){ %>
<li class="py-3 flex items-center gap-3">
<span class="inline-block w-2.5 h-2.5 rounded-full flex-shrink-0
<%= b.status === 'Einstellung' ? 'bg-green-500' :
b.status === 'Absage' ? 'bg-red-500' :
b.status === 'Vorstellungsgespräch' ? 'bg-yellow-500' :
'bg-gray-300' %>"></span>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate"><%= b.firma %></p>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate"><%= b.stelle %></p>
</div>
<span class="text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap shrink-0"><%= new Date(b.datum).toLocaleDateString('de-DE') %></span>
</li>
<% }); %>
</ul>
<% if (anzahlBewerbungen > bewerbungen.length) { %>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-3">… und <%= anzahlBewerbungen - bewerbungen.length %> weitere.</p>
<% } %>
<% } else { %>
<p class="text-sm text-gray-500 dark:text-gray-400">Noch keine Bewerbungen.</p>
<% } %>
</section>
</div>
</main>
<%- include('partials/footer') %>
<style>
.act-btn{display:inline-flex;align-items:center;gap:.375rem;border-radius:.5rem;padding:.375rem .625rem;font-size:.75rem;font-weight:500;transition:background-color .15s,color .15s;line-height:1;}
.act-neutral{color:#4b5563}.act-neutral:hover{background:#f3f4f6}
.dark .act-neutral{color:#d1d5db}.dark .act-neutral:hover{background:rgba(255,255,255,.06)}
.act-emerald{color:#047857}.act-emerald:hover{background:#ecfdf5}
.dark .act-emerald{color:#6ee7b7}.dark .act-emerald:hover{background:rgba(16,185,129,.12)}
.act-blue{color:#2563eb}.act-blue:hover{background:#eff6ff}
.dark .act-blue{color:#93c5fd}.dark .act-blue:hover{background:rgba(59,130,246,.12)}
.act-danger{color:#e11d48}.act-danger:hover{background:#fff1f2}
.dark .act-danger{color:#fda4af}.dark .act-danger:hover{background:rgba(244,63,94,.12)}
</style>
<script>
// Relative Zeitangaben (data-dt) wie in der Admin-Übersicht formatieren.
(function () {
document.querySelectorAll('time[data-dt]').forEach(function (t) {
var s = t.getAttribute('data-dt');
if (!s) return;
var d = new Date(s.replace(' ', 'T') + 'Z');
if (isNaN(d.getTime())) return;
t.textContent = d.toLocaleDateString('de-DE') + ', ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
});
})();
</script>
<%- include('partials/favorit-script') %>
</body>
</html>
+4 -17
View File
@@ -104,13 +104,14 @@
<% users.forEach(function(u) { %>
<tr>
<td class="py-3 px-6 font-medium text-gray-800 dark:text-gray-100">
<div class="flex items-center gap-2.5">
<a href="/admin/users/<%= u.id %>" class="flex items-center gap-2.5 group -mx-1.5 -my-1 px-1.5 py-1 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700/60 transition-colors">
<span class="grid h-8 w-8 place-items-center rounded-full text-xs font-semibold <%= u.is_admin ? 'bg-blue-600/10 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300' %>">
<%= String(u.username || '?').charAt(0).toUpperCase() %>
</span>
<span><%= u.username %></span>
<span class="group-hover:text-blue-600 dark:group-hover:text-blue-400"><%= u.username %></span>
<% if (u.id === currentUserId) { %><span class="text-xs text-gray-400">(du)</span><% } %>
</div>
<svg class="w-4 h-4 text-gray-300 dark:text-gray-600 group-hover:text-blue-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</a>
</td>
<td class="py-3 pr-4">
<% if (u.is_admin) { %>
@@ -128,11 +129,6 @@
</td>
<td class="py-3 pr-6">
<div class="flex flex-wrap items-center justify-end gap-1.5">
<a href="/jobsuche?user=<%= u.id %>"
class="act-btn act-neutral" title="Suchprofil & Zeitplan dieses Benutzers verwalten">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
<span>Jobsuche</span>
</a>
<% if (u.id !== currentUserId) { %>
<form method="POST" action="/admin/users/<%= u.id %>/impersonate" class="inline"
onsubmit="return confirm('Als „<%= u.username %>“ anmelden? Du siehst dann dessen Konto. Admin-Rechte sind dabei pausiert; über das Banner oben kannst du zurückwechseln.');">
@@ -148,15 +144,6 @@
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path></svg>
<span>Passwort</span>
</button>
<% if (u.id !== currentUserId) { %>
<form method="POST" action="/admin/users/<%= u.id %>/delete" class="inline"
onsubmit="return confirm('Benutzer „<%= u.username %>“ inkl. aller Daten wirklich löschen? Das lässt sich nicht rückgängig machen.');">
<button type="submit" class="act-btn act-danger" title="Benutzer inkl. aller Daten löschen">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
<span>Löschen</span>
</button>
</form>
<% } %>
</div>
</td>
</tr>
+52 -3
View File
@@ -496,10 +496,30 @@
</div>
<div class="space-y-8">
<!-- Bewerbungsunterlagen (KI-generiert) -->
<% const genStatus = application.generierung_status; %>
<% if (genStatus || (anhaenge && anhaenge.length) || (application.stellenbeschreibung && application.stellenbeschreibung.trim())) { %>
<section id="unterlagen" class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8"
<% const zeigeUnterlagen = genStatus || (anhaenge && anhaenge.length) || (application.stellenbeschreibung && application.stellenbeschreibung.trim()); %>
<% if (zeigeUnterlagen) { %>
<!-- Umschalter Unterlagen <-> E-Mail-Korrespondenz -->
<style>
.bw-tab{display:inline-flex;align-items:center;gap:.45rem;padding:.5rem 1rem;border-radius:.625rem;font-size:.875rem;font-weight:500;color:rgb(75 85 99);background:transparent;border:1px solid transparent;cursor:pointer;transition:background-color .15s,color .15s,border-color .15s}
.dark .bw-tab{color:rgb(209 213 219)}
.bw-tab:hover{background:rgb(243 244 246)}
.dark .bw-tab:hover{background:rgb(55 65 81)}
.bw-tab.active{background:rgb(37 99 235);color:#fff}
.bw-tab.active:hover{background:rgb(29 78 216)}
</style>
<div class="flex items-center gap-2" id="rechtsTabs">
<button type="button" data-tab="unterlagen" class="bw-tab active">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
Unterlagen
</button>
<button type="button" data-tab="korrespondenz" class="bw-tab">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg>
E-Mail
</button>
</div>
<!-- Bewerbungsunterlagen (KI-generiert) -->
<section id="unterlagen" class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6"
data-status="<%= genStatus || '' %>">
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Bewerbungsunterlagen</h2>
@@ -1074,6 +1094,35 @@
} catch (e) { /* keep polling */ }
}, 4000);
}
// --- Umschalter Unterlagen <-> E-Mail-Korrespondenz (rechte Spalte) ---
(function () {
var unterlagen = document.getElementById('unterlagen');
var korrespondenz = document.getElementById('korrespondenz');
if (!unterlagen || !korrespondenz) return; // kein Toggle nötig
var tabs = Array.prototype.slice.call(document.querySelectorAll('#rechtsTabs [data-tab]'));
function setActive(name) {
unterlagen.classList.toggle('hidden', name !== 'unterlagen');
korrespondenz.classList.toggle('hidden', name !== 'korrespondenz');
tabs.forEach(function (t) { t.classList.toggle('active', t.getAttribute('data-tab') === name); });
// E-Mail-iframes nachmessen, falls sie beim Laden versteckt waren.
if (name === 'korrespondenz') {
korrespondenz.querySelectorAll('.email-html-frame').forEach(fitFrame);
}
}
function fromHash() {
var h = (location.hash || '').replace(/^#/, '');
return (h === 'unterlagen' || h === 'korrespondenz') ? h : 'unterlagen';
}
setActive(fromHash());
window.addEventListener('hashchange', function () { setActive(fromHash()); });
tabs.forEach(function (t) {
t.addEventListener('click', function () {
history.replaceState(null, '', '#' + t.getAttribute('data-tab'));
setActive(t.getAttribute('data-tab'));
});
});
})();
})();
// Generation form: require at least one document, and keep the