Release v2.5.0 — Client-Versions-Übersicht im Web-Editor

Jeder Client meldet beim Start seinen Status (Name, Abteilung, Plugin- und
Thunderbird-Version) als _clients/<email>.json ins Gitea-Repo — aber nur, wenn
sich tatsächlich etwas geändert hat (kein Commit-Spam pro Start).

Web-Editor: neuer Admin-Unterpunkt "User & Versionen" (Route /api/clients)
mit Tabelle je User und "veraltet"-Markierung für Clients, die nicht auf der
neuesten gemeldeten Version laufen. _clients/ ist in den Ordner-Filtern
ausgenommen, damit es nicht als Abteilung erscheint.

Nebenbei: Web-Editor-Button-Label "Abteilung" -> "Vorlage" (Templates-Tab).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015PZHd9vRsBiH8cKYdQo1Yv
This commit is contained in:
Kendrick Bollens
2026-07-22 14:42:23 +02:00
parent d244273e44
commit 02776311c3
6 changed files with 177 additions and 7 deletions

View File

@@ -23,6 +23,7 @@ const {
const SHARED_FOLDER = '_gemeinsam';
const USER_FOLDER = '_benutzer';
const CONFIG_FOLDER = '_config';
const CLIENTS_FOLDER = '_clients';
const SIG_FOOTERS = 'signatures/footers';
const SIG_HEADERS = 'signatures/headers';
@@ -421,7 +422,7 @@ app.get('/api/departments', wrap(async (_req, res) => {
const entries = await listDir('');
const departments = entries
.filter(e => e.type === 'dir'
&& ![SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, 'signatures'].includes(e.name)
&& ![SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, CLIENTS_FOLDER, 'signatures'].includes(e.name)
&& !e.name.startsWith('.'))
.map(e => e.name)
.sort((a, b) => a.localeCompare(b, 'de'));
@@ -450,7 +451,7 @@ app.get('/api/files', wrap(async (req, res) => {
// Full inventory in ONE request (recursive tree), strukturiert aus den Pfaden.
app.get('/api/tree', wrap(async (_req, res) => {
const files = await listAllFiles();
const special = [SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, 'signatures'];
const special = [SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, CLIENTS_FOLDER, 'signatures'];
const dirOf = (p) => { const i = p.lastIndexOf('/'); return i < 0 ? '' : p.slice(0, i); };
const baseOf = (p) => p.slice(p.lastIndexOf('/') + 1);
@@ -511,6 +512,31 @@ app.delete('/api/file', wrap(async (req, res) => {
res.json({ success: true });
}));
// Client-Status-Übersicht: liest alle _clients/<email>.json und liefert die
// gemeldeten Plugin-/TB-Versionen je User (Basis für den Admin-Tab).
app.get('/api/clients', wrap(async (_req, res) => {
const entries = await listDir(CLIENTS_FOLDER);
const clients = [];
for (const e of entries) {
if (e.type !== 'file' || !e.name.endsWith('.json')) continue;
const data = await getFile(e.path);
if (!data) continue;
try {
const c = JSON.parse(fromBase64(data.content));
clients.push({
name: c.name || '',
email: c.email || e.name.replace(/\.json$/, ''),
department: c.department || '',
pluginVersion: c.pluginVersion || '',
tbVersion: c.tbVersion || '',
lastSeen: c.lastSeen || '',
});
} catch (_) { /* kaputte Statusdatei überspringen */ }
}
clients.sort((a, b) => (a.name || a.email).localeCompare(b.name || b.email, 'de'));
res.json({ clients });
}));
// Read/write the email→department mapping.
app.get('/api/abteilungen', wrap(async (_req, res) => {
const data = await getFile(`${CONFIG_FOLDER}/abteilungen.json`);
@@ -548,7 +574,7 @@ app.put('/api/schlagwoerter', wrap(async (req, res) => {
app.delete('/api/departments', wrap(async (req, res) => {
const name = (req.body?.name || '').trim();
if (!name) return res.status(400).json({ error: 'Kein Name angegeben' });
if ([SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, 'signatures'].includes(name) || name.includes('/') || name.includes('..')) {
if ([SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, CLIENTS_FOLDER, 'signatures'].includes(name) || name.includes('/') || name.includes('..')) {
return res.status(400).json({ error: 'Geschützter oder ungültiger Ordner' });
}
const entries = await listDir(name);