Rebrand to TESM: infra rename, LDAP/AD login, selective import/export, Papierkorb

- Vollständiges Rebranding "PoE Manager" -> TESM (TimEShepManager): neue
  Logos/Favicon (theme-aware), Sidebar/Login-Branding, Copyright-Zeile.
- Infrastruktur-Umbenennung: srv/poe_manager -> srv/tesm, alle POE_*-Env-Vars
  -> TESM_* (POE_SCRIPT bewusst unverändert, echtes PoE-Skript), systemd
  Units poe_web/rpi-check* -> tesm/tesm-check*, nginx-Site, netplan/logrotate
  Configs, Gitea-Repo alientim/Aruba-PoE-Modern -> alientim/tesm.
- LDAP/Active-Directory Same-Sign-On: Search+Bind, AD-Gruppen->App-Gruppen-
  Zuordnung (additiv), Konto-Sperren, AD-Vorab-Suche/-Anlage, eigene
  Einstellungsseite.
- Selektives Import/Export (8 Kategorien, Zwei-Schritt-Vorschau) mit eigenem
  R/E/X-Rechtemodell (X = Export, getrennt von E = Import).
- Papierkorb: Soft-Delete statt Hard-Delete für Geräte/Switche/Zugangsdaten/
  Benutzer/Gruppen (AD-Nutzer ausgenommen), eigene Seite unterhalb Wartung,
  konfigurierbare Aufbewahrungsfrist.
- rpi_ip -> ip Spalten-/Code-Umbenennung (Geräte sind längst nicht mehr auf
  Raspberry Pis beschränkt).
- README auf aktuellen Stand gebracht.
This commit is contained in:
2026-08-12 22:21:52 +02:00
parent 49634da0d9
commit d1b10bd970
50 changed files with 3641 additions and 728 deletions
+98
View File
@@ -0,0 +1,98 @@
<!-- Live-Update-fähiges Fragment: wird von index.html initial eingebunden UND
unverändert von /dashboard/tiles (AJAX) nachgeladen, damit das Dashboard
ohne vollen Seiten-Reload aktuell bleibt (siehe dashboard-tiles.js im
scripts-Block von index.html). -->
<div id="dashboard-tiles" data-last-run-ms="{{ last_run_epoch_ms or '' }}">
<div class="stat-row">
<div class="stat-card" data-status-filter="online">
<div class="stat-label">Online</div>
<div class="stat-value online">{{ stats.online }}</div>
</div>
<div class="stat-card" data-status-filter="offline">
<div class="stat-label">Offline</div>
<div class="stat-value offline">{{ stats.offline }}</div>
</div>
{% if current_user.is_authenticated %}
<div class="stat-card" data-status-filter="disabled">
<div class="stat-label">Deaktiviert</div>
<div class="stat-value disabled">{{ stats.disabled }}</div>
</div>
{% endif %}
<div class="stat-card" data-status-filter="">
<div class="stat-label">Gesamt</div>
<div class="stat-value">{{ stats.total }}</div>
</div>
</div>
{% macro device_tile(d) %}
{% set st = status.get(d['mac'], 'unbekannt') %}
<div class="device-card {% if d['is_active'] == 0 %}is-disabled{% endif %} {% if not current_user.is_authenticated %}is-readonly{% endif %}"
data-status="{{ 'disabled' if d['is_active'] == 0 else st }}"
data-mac="{{ d['mac'] }}"
data-name="{{ d['name'] }}"
{% if current_user.is_authenticated %}data-ip="{{ d['ip'] }}"{% endif %}
data-switch="{{ d['switch_hostname'] or '-' }}"
data-port="{{ d['port'] or '-' }}"
data-active="{{ d['is_active'] }}"
data-checked="{{ last_checked.get(d['mac']) or '-' }}"
{% if last_seen.get(d['mac']) %}title="{{ last_seen[d['mac']] }}"{% endif %}>
<div class="dc-top">
<div class="dc-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2.5"/><path d="M8 2v3M16 2v3M8 19v3M16 19v3M2 8h3M2 16h3M19 8h3M19 16h3"/></svg>
</div>
{% if d['is_active'] == 0 %}
<span class="pill disabled">Deaktiviert</span>
{% else %}
<span class="pill {{ st }}">{{ {'online':'Online','offline':'Offline','unbekannt':'Unbekannt'}[st] }}</span>
{% endif %}
</div>
<div class="dc-name">{{ d['name'] }}</div>
{% if current_user.is_authenticated %}<div class="dc-ip">{{ d['ip'] }}</div>{% endif %}
</div>
{% endmacro %}
{% if device_count %}
{% macro section_chevron() %}<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>{% endmacro %}
{% if offline_devices %}
<div class="dash-section" data-status="offline">
<div class="dash-section-title">{{ section_chevron() }}Offline <span class="pill offline">{{ offline_devices|length }}</span></div>
<div class="device-grid">
{% for d in offline_devices %}{{ device_tile(d) }}{% endfor %}
</div>
</div>
{% endif %}
{% if online_devices %}
<div class="dash-section" data-status="online">
<div class="dash-section-title">{{ section_chevron() }}Online <span class="pill online">{{ online_devices|length }}</span></div>
<div class="device-grid">
{% for d in online_devices %}{{ device_tile(d) }}{% endfor %}
</div>
</div>
{% endif %}
{% if disabled_devices %}
<div class="dash-section" data-status="disabled">
<div class="dash-section-title">{{ section_chevron() }}Deaktiviert <span class="pill disabled">{{ disabled_devices|length }}</span></div>
<div class="device-grid">
{% for d in disabled_devices %}{{ device_tile(d) }}{% endfor %}
</div>
</div>
{% endif %}
<p id="noSearchResults" class="text-faint hidden" style="text-align:center; padding:30px 0;">Kein Gerät gefunden.</p>
{% else %}
<div class="card card-pad" style="text-align:center; color:var(--text-faint);">
{% if current_user.is_authenticated %}
Noch keine Geräte angelegt. Füge welche unter <a href="{{ url_for('devices') }}" style="color:var(--accent-strong); font-weight:600;">Devices</a> hinzu.
{% else %}
Aktuell keine Geräte verfügbar.
{% endif %}
</div>
{% endif %}
</div>
+64
View File
@@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block page_title %}Mein Konto{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ current_user.username }}</div>{% endblock %}
{% block content %}
<div class="settings-grid">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Profil</h2>
<div class="hint">Name und Profilbild ändern sich in der Sidebar und im Änderungslog.</div>
</div>
</div>
<div style="display:flex; align-items:center; gap:14px; margin-bottom:20px;">
{% if current_user.avatar_url %}
<img src="{{ current_user.avatar_url }}" alt="" style="width:56px; height:56px; border-radius:50%; object-fit:cover;">
{% else %}
<div class="user-avatar" style="width:56px; height:56px; font-size:18px;">{{ current_user.username[:2]|upper }}</div>
{% endif %}
<form method="post" action="{{ url_for('profile') }}" enctype="multipart/form-data" id="avatarForm" style="flex:1;">
<input type="file" name="avatar" id="avatarInput" accept="image/png,image/jpeg,image/gif,image/webp" style="display:none;" onchange="document.getElementById('avatarForm').requestSubmit();">
<button type="button" class="btn btn-secondary" style="width:100%;" onclick="document.getElementById('avatarInput').click();">Profilbild ändern</button>
<input type="hidden" name="upload_avatar" value="1">
</form>
</div>
<form method="post" action="{{ url_for('profile') }}" id="profileForm">
<div class="field"><label>Vorname</label><input type="text" name="first_name" value="{{ current_user.first_name or '' }}"></div>
<div class="field"><label>Name</label><input type="text" name="last_name" value="{{ current_user.last_name or '' }}"></div>
<div class="field"><label>Username</label><input type="text" value="{{ current_user.username }}" disabled></div>
<button type="submit" name="update_profile" value="1" class="btn btn-primary btn-block">Speichern</button>
</form>
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Passwort ändern</h2>
<div class="hint">
{% if current_user.is_ldap_user %}Wird über Active Directory verwaltet.{% else %}Erfordert Eingabe des aktuellen Passworts.{% endif %}
</div>
</div>
</div>
{% if current_user.is_ldap_user %}
<p class="text-faint" style="font-size:12.5px;">
Dieses Konto meldet sich über Active Directory/LDAP an — das Passwort wird dort verwaltet
und kann in dieser App nicht geändert werden. Bitte das Domänen-Passwort wie gewohnt ändern.
</p>
{% else %}
<form method="post" action="{{ url_for('profile') }}" id="passwordForm">
<div class="field"><label>Aktuelles Passwort</label><input type="password" name="current_password" autocomplete="current-password"></div>
<div class="field"><label>Neues Passwort</label><input type="password" name="new_password" autocomplete="new-password"></div>
<div class="field"><label>Neues Passwort bestätigen</label><input type="password" name="confirm_password" autocomplete="new-password"></div>
<button type="submit" name="change_password" value="1" class="btn btn-secondary btn-block">Passwort ändern</button>
</form>
{% endif %}
</div>
</div>
{% endblock %}
+75
View File
@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% set active_page = "logs" %}
{% block page_title %}Änderungen{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ entries|length }} Einträge (letzte 500)</div>{% endblock %}
{% block content %}
{% set action_labels = {
"settings.update": "Einstellung geändert",
"device.create": "Gerät angelegt",
"device.edit": "Gerät bearbeitet",
"device.delete": "Gerät gelöscht",
"device.activate": "Gerät aktiviert",
"device.deactivate": "Gerät deaktiviert",
"switch.create": "Switch angelegt",
"switch.edit": "Switch bearbeitet",
"switch.delete": "Switch gelöscht",
"credential.create": "Zugangsdaten angelegt",
"credential.edit": "Zugangsdaten bearbeitet",
"credential.delete": "Zugangsdaten gelöscht",
"user.create": "Benutzer angelegt",
"user.edit": "Benutzer bearbeitet",
"user.delete": "Benutzer gelöscht",
"user.assign_group": "Gruppe zugewiesen",
"group.create": "Gruppe angelegt",
"group.edit": "Gruppe bearbeitet",
"group.delete": "Gruppe gelöscht",
"group.assign_admins": "Admin-Zuweisung geändert",
"profile.update": "Profil aktualisiert",
"profile.password": "Passwort geändert",
"check.run_now": "Prüfung manuell gestartet",
} %}
{% set action_icons = {
"delete": '<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/>',
"create": '<path d="M12 5v14M5 12h14"/>',
"edit": '<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/>',
"activate": '<path d="M20 6L9 17l-5-5"/>',
"deactivate": '<circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6M9 9l6 6"/>',
} %}
<div class="table-wrap">
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th style="width:1%;"></th><th>Zeitpunkt</th><th>Benutzer</th><th>Aktion</th><th>Ziel</th><th>Details</th></tr></thead>
<tbody>
{% for e in entries %}
{% set kind = e['action'].split('.')[-1] %}
<tr>
<td>
{% if avatars.get(e['username']) %}
<img class="avatar-sm" src="{{ url_for('static', filename='uploads/avatars/' + avatars[e['username']]) }}" alt="">
{% else %}
<span class="avatar-sm avatar-placeholder">{{ e['username'][:1]|upper }}</span>
{% endif %}
</td>
<td class="text-dim mono" style="font-size:12.5px;">{{ e['ts'] }}</td>
<td class="cell-name">{{ e['username'] }}</td>
<td>
<span class="pill action-pill">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ (action_icons.get(kind) or action_icons['edit'])|safe }}</svg>
{{ action_labels.get(e['action'], e['action']) }}
</span>
</td>
<td>{{ e['target'] or '—' }}</td>
<td class="text-dim">{{ e['details'] or '—' }}</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="6">Noch keine Änderungen protokolliert.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+155
View File
@@ -0,0 +1,155 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title or "TESM" }}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='images/icon-dark.svg') }}" id="app-favicon">
<link rel="stylesheet" href="{{ asset_url('css/style.css') }}">
{% block extra_head %}{% endblock %}
</head>
<body>
{% set icons = {
"grid": '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
"cpu": '<rect x="6" y="6" width="12" height="12" rx="1.5"/><path d="M9 1v3M15 1v3M9 20v3M15 20v3M1 9h3M1 15h3M20 9h3M20 15h3"/>',
"share": '<circle cx="18" cy="5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="19" r="2.5"/><path d="M8.2 10.7l7.6-4.4M8.2 13.3l7.6 4.4"/>',
"users": '<circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/>',
"groups": '<rect x="3" y="4" width="8" height="7" rx="1.5"/><rect x="13" y="4" width="8" height="7" rx="1.5"/><rect x="3" y="13" width="8" height="7" rx="1.5"/><rect x="13" y="13" width="8" height="7" rx="1.5"/>',
"key": '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>',
"terminal": '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/>',
"history": '<path d="M3 12a9 9 0 109-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
"sliders": '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h13M20 18h0"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>',
"logout": '<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/>',
"gear": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>',
"transfer": '<path d="M17 3l4 4-4 4"/><path d="M3 7h18"/><path d="M7 21l-4-4 4-4"/><path d="M21 17H3"/>',
"network": '<rect x="9" y="2" width="6" height="6" rx="1.2"/><rect x="2" y="16" width="6" height="6" rx="1.2"/><rect x="16" y="16" width="6" height="6" rx="1.2"/><path d="M12 8v4M12 12H5v4M12 12h7v4"/>',
"wrench": '<path d="M14.7 6.3a4 4 0 11-5.4 5.4L3 18l3 3 6.3-6.3a4 4 0 015.4-5.4z"/>',
"trash": '<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6h16z"/><path d="M10 11v6M14 11v6"/>',
} %}
<div class="app-shell">
{% if current_user.is_authenticated %}
<div class="sidebar-backdrop" data-sidebar-toggle></div>
<aside class="sidebar">
<div class="sidebar-brand">
<img id="sidebar-logo" src="{{ url_for('static', filename='images/logo-dark.svg') }}" alt="TESM" style="width:100%; height:auto; display:block;">
</div>
<nav class="sidebar-nav">
{% for item in nav_items_ordered %}
{% if item.children %}
{% set child_active = item.children|selectattr('endpoint', 'equalto', request.endpoint)|list %}
<div class="nav-group {% if child_active %}expanded active-group{% endif %}" data-nav-group data-nav-group-key="{{ item.key }}">
<button type="button" class="nav-group-toggle" data-nav-group-toggle>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons[item.icon]|safe }}</svg>
{{ item.label }}
<svg class="nav-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
</button>
<div class="nav-group-children">
{% for child in item.children %}
<a href="{{ url_for(child.endpoint) }}" class="nav-item {% if request.endpoint == child.endpoint %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons[child.icon]|safe }}</svg>
{{ child.label }}
</a>
{% endfor %}
</div>
</div>
{% else %}
<a href="{{ url_for(item.endpoint) }}" class="nav-item {% if active_page == item.key %}active{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons[item.icon]|safe }}</svg>
{{ item.label }}
</a>
{% endif %}
{% endfor %}
</nav>
<div class="sidebar-footer">
<div class="user-chip">
{% if current_user.avatar_url %}
<img class="user-avatar" src="{{ current_user.avatar_url }}" alt="" style="object-fit:cover;">
{% else %}
<div class="user-avatar">{{ current_user.username[:2]|upper }}</div>
{% endif %}
<div class="user-meta">
<div class="u-name">{{ current_user.display_name }}</div>
<div class="u-role">{{ "Administrator" if current_user.is_admin else current_user.group_names or "Benutzer" }}</div>
</div>
<a href="{{ url_for('account') }}" class="icon-btn" title="Einstellungen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['gear']|safe }}</svg>
</a>
</div>
<div class="footer-actions">
<button type="button" class="icon-btn" data-theme-toggle title="Theme wechseln">
<span data-theme-icon></span>
</button>
<a href="{{ url_for('logout') }}" class="icon-btn" title="Abmelden" style="flex:1; display:flex;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin:auto;">{{ icons['logout']|safe }}</svg>
</a>
</div>
<div class="sidebar-copyright" style="padding:10px 20px 4px; font-size:10.5px; color:var(--text-faint); text-align:center;">
© {{ current_year }} TESM — TimEShepManager
</div>
</div>
</aside>
{% endif %}
<div class="main {% if not current_user.is_authenticated %}no-sidebar{% endif %}">
<div class="topbar">
<div class="topbar-left">
{% if current_user.is_authenticated %}
<button type="button" class="hamburger" data-sidebar-toggle aria-label="Menü">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
</button>
{% endif %}
<div>
<div class="topbar-title">{% block page_title %}Dashboard{% endblock %}</div>
{% block page_sub %}{% endblock %}
</div>
</div>
<div class="topbar-logo">
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="WiS">
</div>
<div class="topbar-right">
<span class="timer-pill" id="global-timer-pill"
data-last-run-ms="{{ global_last_run_epoch_ms or '' }}"
data-interval-ms="{{ (global_check_interval * 60000) if global_check_interval else '' }}">
<span class="dot"></span>Nächste Prüfung in <span id="global-timer">--</span>s
{% if current_user.is_admin %}
<button type="button" class="timer-pill-refresh" id="run-check-now" title="Jetzt prüfen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
</button>
{% endif %}
</span>
{% if dhcp_topbar_active is not none %}
<span class="timer-pill" title="DHCP-Dienst: {{ 'aktiv' if dhcp_topbar_active else 'inaktiv' }}">
<span class="dot" style="animation:none; background:{{ 'var(--success)' if dhcp_topbar_active else 'var(--danger)' }}; box-shadow:none;"></span>DHCP
</span>
{% endif %}
{% if not current_user.is_authenticated %}
<a href="{{ url_for('login') }}" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4"/><path d="M10 17l5-5-5-5"/><path d="M15 12H3"/></svg>
Login
</a>
{% endif %}
</div>
</div>
<div class="content">
{% block content %}{% endblock %}
</div>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
<script type="application/json" id="flashed-data">{{ messages|tojson }}</script>
{% endwith %}
<script src="{{ asset_url('js/app.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+202
View File
@@ -0,0 +1,202 @@
{% extends "base.html" %}
{% set active_page = "credentials" %}
{% set can_create = current_user.has_permission('credentials.create') %}
{% set can_edit = current_user.has_permission('credentials.edit') %}
{% set can_delete = current_user.has_permission('credentials.edit') %}
{% set category_labels = dict(categories) %}
{% block page_title %}Zugangsdaten{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ credentials|length }} Zugangsdaten</div>{% endblock %}
{% block content %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Zugangsdaten</h2>
<div class="hint">Können mehreren Switchen zugleich zugeordnet werden.</div>
</div>
{% if can_create %}
<button type="button" class="btn btn-primary" data-open-modal="addCredentialModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neue Zugangsdaten
</button>
{% endif %}
</div>
<div class="table-wrap">
<div class="table-toolbar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input type="text" id="credentialSearch" placeholder="Zugangsdaten durchsuchen…" oninput="filterTable('credentialSearch','credentialsTable')">
</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table" id="credentialsTable" data-sortable>
<thead><tr>
<th data-sort-key="name">Name</th>
<th data-sort-key="username">Username</th>
<th data-sort-key="category">Kategorie</th>
<th data-sort-key="usage">Verwendet von</th>
<th style="width:1%;">Aktionen</th>
</tr></thead>
<tbody>
{% for c in credentials %}
{% set usage_total = c['switch_count'] + c['device_count'] %}
<tr data-sort-name="{{ c['name']|lower }}" data-sort-username="{{ c['username']|lower }}" data-sort-category="{{ c['category'] }}" data-sort-usage="{{ usage_total }}">
<td class="cell-name">{{ c['name'] }}</td>
<td class="mono">{{ c['username'] }}</td>
<td>{{ category_labels.get(c['category'], c['category']) }}</td>
<td class="text-dim">
{% if usage_total == 0 %}—{% else %}
{% if c['switch_count'] %}{{ c['switch_count'] }} Switch{{ 'e' if c['switch_count'] != 1 else '' }}{% endif %}
{% if c['switch_count'] and c['device_count'] %}, {% endif %}
{% if c['device_count'] %}{{ c['device_count'] }} Gerät{{ 'e' if c['device_count'] != 1 else '' }}{% endif %}
{% endif %}
</td>
<td>
<div class="row-actions">
{% if can_edit %}
<button class="icon-btn" title="Bearbeiten"
onclick="openEditCredentialModal({{ c['id'] }}, '{{ c['name'] }}', '{{ c['username'] }}', '{{ c['category'] }}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
</button>
{% endif %}
{% if can_delete %}
<form method="post" data-confirm="Zugangsdaten „{{ c['name'] }}“ wirklich löschen?">
<input type="hidden" name="delete_credential" value="{{ c['id'] }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="5">Noch keine Zugangsdaten vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if can_create %}
<!-- Modal: Neue Zugangsdaten -->
<div class="modal-overlay" id="addCredentialModal">
<div class="modal" style="max-width:1000px;">
<form method="post" onsubmit="return validateCredentialForm(this, 'add');">
<div class="modal-header">
<h3>Neue Zugangsdaten</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<input type="hidden" name="add_credential" value="1">
<div class="field"><label>Name</label>
<input type="text" name="name" required placeholder="z.B. Standard-Switch-Login">
</div>
<div class="field"><label>Username</label>
<input type="text" name="username" required placeholder="z.B. admin">
</div>
<div class="field"><label>Kategorie</label>
<select name="category">
{% for key, label in categories %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<div class="hint">Bestimmt u.a., ob diese Zugangsdaten unter „Wartung“ für Bulk-Updates nutzbar sind.</div>
</div>
<div class="field"><label>Passwort</label>
<input type="password" id="password_add" name="password" required>
</div>
<div class="field"><label>Passwort bestätigen</label>
<input type="password" id="password_confirm_add" name="password_confirm" required>
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Anlegen</button>
</div>
</form>
</div>
</div>
{% endif %}
{% if can_edit %}
<!-- Modal: Zugangsdaten bearbeiten -->
<div class="modal-overlay" id="editCredentialModal">
<div class="modal" style="max-width:380px;">
<form method="post" onsubmit="return validateCredentialForm(this, 'edit');">
<input type="hidden" name="edit_credential" value="1">
<input type="hidden" name="credential_id" id="edit_cred_id">
<div class="modal-header">
<h3>Zugangsdaten bearbeiten</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field"><label>Name</label>
<input type="text" name="name" id="edit_cred_name" required>
</div>
<div class="field"><label>Username</label>
<input type="text" name="username" id="edit_cred_username" required>
</div>
<div class="field"><label>Kategorie</label>
<select name="category" id="edit_cred_category">
{% for key, label in categories %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="field"><label>Neues Passwort</label>
<input type="password" id="password_edit" name="password" placeholder="Nur bei Änderung ausfüllen">
</div>
<div class="field"><label>Passwort bestätigen</label>
<input type="password" id="password_confirm_edit" name="password_confirm" placeholder="Bestätigen">
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
function openEditCredentialModal(id, name, username, category) {
document.getElementById("edit_cred_id").value = id;
document.getElementById("edit_cred_name").value = name;
document.getElementById("edit_cred_username").value = username;
document.getElementById("edit_cred_category").value = category;
document.getElementById("password_edit").value = "";
document.getElementById("password_confirm_edit").value = "";
PoeUI.openModal("editCredentialModal");
}
function filterTable(inputId, tableId) {
const q = document.getElementById(inputId).value.trim().toLowerCase();
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
if (row.classList.contains("empty-row")) return;
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
});
}
function validateCredentialForm(form, id) {
const pass = document.getElementById("password_" + id);
const confirm = document.getElementById("password_confirm_" + id);
if (!pass || !confirm) return true;
if (pass.value || confirm.value || id === "add") {
if (pass.value !== confirm.value) {
confirm.classList.add("is-invalid");
return false;
}
}
confirm.classList.remove("is-invalid");
return true;
}
</script>
{% endblock %}
+535
View File
@@ -0,0 +1,535 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/vendor/xterm.css') }}">
{% endblock %}
{% set active_page = "devices" %}
{% set can_toggle = current_user.has_permission('devices.edit') %}
{% set can_create = current_user.has_permission('devices.create') %}
{% set can_edit = current_user.has_permission('devices.edit') %}
{% set can_delete = current_user.has_permission('devices.edit') %}
{% set show_actions_col = can_edit or can_delete %}
{% set col_count = 6 + (1 if can_toggle else 0) + (1 if show_actions_col else 0) %}
{% block page_title %}Clients{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte</div>{% endblock %}
{% block content %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Geräte</h2>
<div class="hint">PoE-Geräte mit IP, MAC und zugeordnetem Switch-Port.</div>
</div>
{% if can_create %}
<button type="button" class="btn btn-primary" data-open-modal="deviceModal" onclick="resetDeviceForm()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neues Gerät
</button>
{% endif %}
</div>
<div class="table-wrap">
<div class="table-toolbar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input type="text" id="deviceSearch" placeholder="Geräte durchsuchen…" oninput="filterTable('deviceSearch','devicesTable')">
</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table" id="devicesTable" data-sortable>
<thead>
<tr>
<th data-sort-key="hostname">Hostname</th>
<th data-sort-key="ip">IP-Adresse</th>
<th data-sort-key="mac">MAC-Adresse</th>
<th data-sort-key="switch">Switch</th>
<th data-sort-key="port">Switchport</th>
<th data-sort-key="credential">Zugangsdaten</th>
{% if can_toggle %}<th>Status</th>{% endif %}
{% if show_actions_col %}<th style="width:1%;">Aktionen</th>{% endif %}
</tr>
</thead>
<tbody>
{% for d in devices %}
<tr data-sort-hostname="{{ d['name']|lower }}" data-sort-ip="{{ d['ip']|lower }}" data-sort-mac="{{ d['mac']|lower }}" data-sort-switch="{{ (d['switch_hostname'] or '')|lower }}" data-sort-port="{{ (d['port'] or '')|lower }}" data-sort-credential="{{ (d['credential_name'] or '')|lower }}">
<td class="cell-name">{{ d['name'] }}</td>
<td class="mono">{{ d['ip'] }}</td>
<td class="mono">{{ d['mac'] }}</td>
<td>{{ d['switch_hostname'] or '—' }}</td>
<td>{{ d['port'] or '—' }}</td>
<td>
{% if d['credential_name'] %}
{{ d['credential_name'] }} <span class="text-faint mono" style="font-size:11.5px;">({{ d['credential_username'] }})</span>
{% else %}
<span class="text-faint">— keine —</span>
{% endif %}
</td>
{% if can_toggle %}
<td>
<label class="switch-check">
<input type="checkbox" {% if d['is_active'] %}checked{% endif %} onchange="toggleDevice('{{ d['mac'] }}', this)">
<span class="track"></span>
</label>
</td>
{% endif %}
{% if show_actions_col %}
<td>
<div class="row-actions">
{% if can_edit %}
<button class="icon-btn" title="Bearbeiten"
onclick="openEditDeviceModal('{{ d['mac'] }}','{{ d['name'] }}','{{ d['ip'] }}','{{ d['port'] or '' }}','{{ d['ssh_port'] or '' }}','{{ d['credential_id'] or '' }}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
</button>
<button class="icon-btn" title="Switch ändern"
onclick="openSwitchModal('{{ d['mac'] }}','{{ d['switch_hostname'] or '' }}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="19" r="2.5"/><path d="M8.2 10.7l7.6-4.4M8.2 13.3l7.6 4.4"/></svg>
</button>
{% endif %}
{% if can_delete %}
<form method="post" data-confirm="Willst du „{{ d['name'] }}“ wirklich löschen?">
<input type="hidden" name="delete_device" value="{{ d['mac'] }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
{% endif %}
</div>
</td>
{% endif %}
</tr>
{% else %}
<tr class="empty-row"><td colspan="{{ col_count }}">Noch keine Geräte vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if can_create %}
<!-- Modal: Neues Gerät -->
<div class="modal-overlay" id="deviceModal">
<div class="modal" style="max-width:1000px;">
<form method="post" onsubmit="return validateDeviceForm(this);">
<div class="modal-header">
<h3>Neues Gerät hinzufügen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<input type="hidden" name="add_device" value="1">
<div class="field"><label>Hostname</label>
<input type="text" name="name" required placeholder="z.B. Sensor01">
</div>
<div class="field"><label>IP-Adresse</label>
<input type="text" name="ip" required placeholder="z.B. 192.168.1.100">
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
</div>
<div class="field"><label>MAC-Adresse</label>
<input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF">
<div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div>
</div>
<div class="field"><label>Switchport</label>
<input type="text" name="port" placeholder="z.B. 3">
<div class="field-hint">Physische Port-Nummer AM SWITCH (z.B. Port 3 von 48), an dem das Gerät eingesteckt ist — für den PoE-Neustart. Kein Netzwerk-/TCP-Port, keine SSH-Anmeldung.</div>
</div>
<div class="field"><label>Switch (optional)</label>
<select name="switch_hostname">
<option value="">Kein Switch</option>
{% for sw in switches %}<option value="{{ sw['hostname'] }}">{{ sw['hostname'] }}</option>{% endfor %}
</select>
</div>
<div class="permission-group-title">SSH-Zugriff (optional, z.B. für Update-Aktionen unter „Wartung“)</div>
<div class="field"><label>SSH-Port</label>
<input type="number" name="ssh_port" min="1" max="65535" placeholder="22 (Standard)">
<div class="field-hint">TCP-Netzwerk-Port für die SSH-Anmeldung an der IP-Adresse dieses Geräts (siehe Feld „IP-Adresse“ oben). Leer lassen, wenn Standard-Port 22 verwendet wird.</div>
</div>
<div class="field">
<label>Zugangsdaten</label>
<select name="credential_choice" class="credential-select" onchange="toggleNewCredentialFields(this)">
<option value="">Keine (nicht per SSH verwaltet)</option>
{% for c in all_credentials %}
<option value="{{ c['id'] }}" data-username="{{ c['username'] }}">{{ c['name'] }} ({{ c['category'] }})</option>
{% endfor %}
<option value="new">+ Neue Zugangsdaten anlegen</option>
</select>
</div>
<div class="new-credential-fields hidden">
<div class="field"><label>Name der Zugangsdaten</label>
<input type="text" name="new_credential_name" placeholder="z.B. Linux-Clients">
</div>
<div class="field"><label>Username</label>
<input type="text" name="new_credential_username" placeholder="z.B. admin">
</div>
<div class="field"><label>Passwort</label>
<input type="password" name="new_credential_password">
</div>
<div class="field"><label>Passwort bestätigen</label>
<input type="password" name="new_credential_password_confirm">
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" style="margin-right:auto;" onclick="openTerminal(this.closest('form'))">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/></svg>
Verbindung testen
</button>
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
</div>
</div>
{% endif %}
{% if can_edit %}
<!-- Modal: Bearbeiten -->
<div class="modal-overlay" id="editDeviceModal">
<div class="modal" style="max-width:1000px;">
<form method="post" onsubmit="return validateDeviceForm(this);">
<input type="hidden" name="edit_device" value="1">
<input type="hidden" name="old_mac" id="edit_old_mac">
<div class="modal-header">
<h3>Gerät bearbeiten</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field"><label>Hostname</label>
<input type="text" name="name" id="edit_name" required>
</div>
<div class="field"><label>IP-Adresse</label>
<input type="text" name="ip" id="edit_ip" required>
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
</div>
<div class="field"><label>MAC-Adresse</label>
<input type="text" name="mac" id="edit_mac" required>
<div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div>
</div>
<div class="field"><label>Switchport</label>
<input type="text" name="port" id="edit_port">
<div class="field-hint">Portnummer am zugeordneten Switch, für den PoE-Neustart. Hat nichts mit dem SSH-Port unten zu tun.</div>
</div>
<div class="permission-group-title">SSH-Zugriff (optional, z.B. für Update-Aktionen unter „Wartung“)</div>
<div class="field"><label>SSH-Port</label>
<input type="number" name="ssh_port" id="edit_ssh_port" min="1" max="65535" placeholder="22 (Standard)">
<div class="field-hint">TCP-Netzwerk-Port für die SSH-Anmeldung an der IP-Adresse dieses Geräts (siehe Feld „IP-Adresse“ oben). Leer lassen, wenn Standard-Port 22 verwendet wird.</div>
</div>
<div class="field">
<label>Zugangsdaten</label>
<select name="credential_choice" id="edit_credential_choice" class="credential-select" onchange="toggleNewCredentialFields(this)">
<option value="">Keine (nicht per SSH verwaltet)</option>
{% for c in all_credentials %}
<option value="{{ c['id'] }}" data-username="{{ c['username'] }}">{{ c['name'] }} ({{ c['category'] }})</option>
{% endfor %}
<option value="new">+ Neue Zugangsdaten anlegen</option>
</select>
</div>
<div class="new-credential-fields hidden">
<div class="field"><label>Name der Zugangsdaten</label>
<input type="text" name="new_credential_name" placeholder="z.B. Linux-Clients">
</div>
<div class="field"><label>Username</label>
<input type="text" name="new_credential_username" placeholder="z.B. admin">
</div>
<div class="field"><label>Passwort</label>
<input type="password" name="new_credential_password">
</div>
<div class="field"><label>Passwort bestätigen</label>
<input type="password" name="new_credential_password_confirm">
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" style="margin-right:auto;" onclick="openTerminal(this.closest('form'))">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/></svg>
Verbindung testen
</button>
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
<!-- Modal: Switch ändern -->
<div class="modal-overlay" id="switchModal">
<div class="modal" style="max-width:380px;">
<form method="post">
<input type="hidden" name="edit_device" value="1">
<input type="hidden" name="old_mac" id="switch_mac">
<div class="modal-header">
<h3>Switch ändern</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>Zugeordneter Switch</label>
<select name="switch_hostname" id="switch_select">
<option value="">Kein Switch</option>
{% for sw in switches %}<option value="{{ sw['hostname'] }}">{{ sw['hostname'] }}</option>{% endfor %}
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
{% endif %}
{% if can_create or can_edit %}
<!-- Modal: SSH-Verbindungstest (echtes Terminal, zum Akzeptieren von Host-Keys
und Prüfen der Zugangsdaten, bevor das Gerät gespeichert wird) -->
<div class="modal-overlay" id="terminalModal">
<div class="modal" style="max-width:720px;">
<div class="modal-header">
<h3>SSH-Verbindungstest</h3>
<button type="button" class="modal-close" data-close-modal onclick="closeTerminal()">&times;</button>
</div>
<div class="modal-body" style="padding:0;">
<div class="term-toolbar">
<div class="flex gap-2" style="align-items:center;">
<span id="termStatus" class="pill unknown">Bereit</span>
<span id="termTarget" class="text-faint mono" style="font-size:12px;"></span>
</div>
<button type="button" class="btn btn-sm btn-secondary" id="termPastePassword">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
Passwort einfügen
</button>
</div>
<div id="terminal" class="xterm-container"></div>
</div>
<div class="modal-footer">
<p class="text-faint" style="font-size:11.5px; margin-right:auto;">
Unbekannter Host-Key: "yes" bestätigt und merkt sich ihn dauerhaft.
</p>
<button type="button" class="btn btn-secondary" data-close-modal onclick="closeTerminal()">Schließen</button>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/vendor/xterm.js') }}"></script>
<script src="{{ url_for('static', filename='js/vendor/xterm-addon-fit.js') }}"></script>
<script>
function resetDeviceForm() {
document.querySelector("#deviceModal form").reset();
resetCredentialChoice("add");
}
function openEditDeviceModal(mac, name, ip, port, sshPort, credentialId) {
document.getElementById("edit_old_mac").value = mac;
document.getElementById("edit_name").value = name;
document.getElementById("edit_ip").value = ip;
document.getElementById("edit_mac").value = mac;
document.getElementById("edit_port").value = port;
document.getElementById("edit_ssh_port").value = sshPort || "";
document.getElementById("edit_credential_choice").value = credentialId || "";
PoeUI.openModal("editDeviceModal");
resetCredentialChoice("edit");
}
function openSwitchModal(mac, switchHostname) {
document.getElementById("switch_mac").value = mac;
document.getElementById("switch_select").value = switchHostname;
PoeUI.openModal("switchModal");
}
const ipPattern = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
const macPattern = /^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/;
function validateIP(input) {
const ok = ipPattern.test(input.value);
input.classList.toggle("is-invalid", !ok);
return ok;
}
function validateMAC(input) {
const ok = macPattern.test(input.value);
input.classList.toggle("is-invalid", !ok);
return ok;
}
function validateNewCredentialPassword(form) {
const select = form.querySelector(".credential-select");
if (!select || select.value !== "new") return true;
const pass = form.querySelector("input[name='new_credential_password']");
const confirm = form.querySelector("input[name='new_credential_password_confirm']");
if (!pass || !confirm) return true;
if (pass.value !== confirm.value) { confirm.classList.add("is-invalid"); return false; }
confirm.classList.remove("is-invalid");
return true;
}
function validateDeviceForm(form) {
const ipInput = form.querySelector("input[name='ip']");
const macInput = form.querySelector("input[name='mac']");
let valid = true;
if (ipInput) valid = validateIP(ipInput) && valid;
if (macInput) valid = validateMAC(macInput) && valid;
valid = validateNewCredentialPassword(form) && valid;
return valid;
}
document.addEventListener("input", (e) => {
if (e.target.name === "ip") validateIP(e.target);
if (e.target.name === "mac") validateMAC(e.target);
if (e.target.name === "new_credential_password_confirm") {
const form = e.target.closest("form");
const pass = form.querySelector("input[name='new_credential_password']");
if (pass) e.target.classList.toggle("is-invalid", pass.value !== e.target.value);
}
});
function toggleNewCredentialFields(select) {
const fields = select.closest(".modal-body").querySelector(".new-credential-fields");
if (fields) fields.classList.toggle("hidden", select.value !== "new");
}
function resetCredentialChoice(id) {
// Beim Öffnen sicherstellen, dass die "Neue Zugangsdaten"-Felder passend
// zur aktuellen Auswahl ein-/ausgeblendet sind (relevant v.a. nach
// vorherigem Umschalten auf "neu" ohne zu speichern).
setTimeout(() => {
const select = document.querySelector(`#${id === 'add' ? 'deviceModal' : 'editDeviceModal'} .credential-select`);
if (select) toggleNewCredentialFields(select);
}, 0);
}
// -------------------------------------------------------------------------
// SSH-Verbindungstest (Web-Terminal via /ws/ssh_terminal) — identisch zum
// Identisches Muster wie in switches.html.
// -------------------------------------------------------------------------
let term = null, fitAddon = null, termSocket = null, activePasswordInput = null;
function ensureTerminal() {
if (term) return;
term = new Terminal({
convertEol: true,
fontSize: 13,
fontFamily: "ui-monospace, SFMono-Regular, Consolas, monospace",
cursorBlink: true,
theme: { background: "#0a0c10", foreground: "#c7ccd6" },
});
fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal"));
fitAddon.fit();
term.onData((data) => {
if (termSocket && termSocket.readyState === WebSocket.OPEN) {
termSocket.send(JSON.stringify({ type: "input", data }));
}
});
term.onResize(({ cols, rows }) => {
if (termSocket && termSocket.readyState === WebSocket.OPEN) {
termSocket.send(JSON.stringify({ type: "resize", cols, rows }));
}
});
window.addEventListener("resize", () => fitAddon && fitAddon.fit());
}
function setTermStatus(text, cls) {
const el = document.getElementById("termStatus");
el.className = "pill " + cls;
el.innerText = text;
}
function getCredentialInfo(form) {
const select = form.querySelector(".credential-select");
if (!select || select.value === "new") {
return {
username: (form.querySelector("input[name='new_credential_username']") || {}).value?.trim(),
passwordInput: form.querySelector("input[name='new_credential_password']"),
};
}
const option = select.selectedOptions[0];
return { username: option ? option.dataset.username : null, passwordInput: null };
}
function openTerminal(form) {
const host = (form.querySelector("input[name='ip']") || {}).value?.trim();
const port = parseInt((form.querySelector("input[name='ssh_port']") || {}).value, 10) || 22;
const { username, passwordInput } = getCredentialInfo(form);
activePasswordInput = passwordInput;
if (!host || !username) {
showToast("Bitte IP-Adresse ausfüllen und Zugangsdaten auswählen/anlegen, bevor du die Verbindung testest.", "danger");
return;
}
PoeUI.openModal("terminalModal");
ensureTerminal();
term.reset();
document.getElementById("termTarget").innerText = `${username}@${host}:${port}`;
setTermStatus("Verbinde…", "unknown");
setTimeout(() => fitAddon && fitAddon.fit(), 60);
if (termSocket) { try { termSocket.close(); } catch (e) {} }
const proto = location.protocol === "https:" ? "wss:" : "ws:";
termSocket = new WebSocket(`${proto}//${location.host}/ws/ssh_terminal`);
termSocket.onopen = () => {
termSocket.send(JSON.stringify({ host, username, port }));
setTermStatus("Verbunden", "online");
};
termSocket.onmessage = (event) => term.write(event.data);
termSocket.onclose = () => setTermStatus("Getrennt", "offline");
termSocket.onerror = () => setTermStatus("Fehler", "offline");
}
function closeTerminal() {
if (termSocket) {
try { termSocket.close(); } catch (e) {}
termSocket = null;
}
}
const termPasteBtn = document.getElementById("termPastePassword");
if (termPasteBtn) {
termPasteBtn.addEventListener("click", () => {
if (!activePasswordInput || !activePasswordInput.value) {
showToast("Kein Passwort bekannt — bitte manuell im Terminal eingeben.", "danger");
return;
}
if (!termSocket || termSocket.readyState !== WebSocket.OPEN) {
showToast("Keine aktive Terminal-Verbindung.", "danger");
return;
}
termSocket.send(JSON.stringify({ type: "input", data: activePasswordInput.value + "\n" }));
});
}
const terminalModalEl = document.getElementById("terminalModal");
if (terminalModalEl) {
terminalModalEl.addEventListener("click", (e) => {
if (e.target.id === "terminalModal") closeTerminal();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && terminalModalEl.classList.contains("open")) closeTerminal();
});
}
function toggleDevice(mac, checkbox) {
checkbox.disabled = true;
fetch(`/devices/toggle/${mac}`, { method: "POST" })
.then(r => r.json())
.then(data => {
checkbox.disabled = false;
if (data.success) {
checkbox.checked = data.new_status === 1;
showToast(data.msg, "success");
} else {
checkbox.checked = !checkbox.checked;
showToast(data.msg, "danger");
}
})
.catch(() => { checkbox.disabled = false; checkbox.checked = !checkbox.checked; showToast("Fehler beim Umschalten.", "danger"); });
}
function filterTable(inputId, tableId) {
const q = document.getElementById(inputId).value.trim().toLowerCase();
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
if (row.classList.contains("empty-row")) return;
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
});
}
</script>
{% endblock %}
+420
View File
@@ -0,0 +1,420 @@
{% extends "base.html" %}
{% set active_page = "groups" %}
{% block page_title %}Gruppen{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ groups|length + 1 }} Gruppen · Rechteverwaltung</div>{% endblock %}
{% block content %}
{# Rendert einen Bereich (Geräte/Logs/Einstellungen) als kompakte, schmale
Spalte: eigene Kopfzeile mit Bereichsname + einem Kästchen (der
Kill-Switch, "Bereich sichtbar"), darunter eine kleine Tabelle mit den
Unterpunkten (Clients, Switche, ...) als Zeilen und nur den für diesen
Bereich tatsächlich genutzten Rechten (group_row_types[group_key]) als
Spalten — Logs/Einstellungen zeigen z.B. nur R/W/E, nur Geräte zeigt
zusätzlich D (PoE-Neustart über Dashboard, ausschließlich bei Clients).
"Ändern" (E) deckt Löschen überall mit ab — es gibt nirgends ein eigenes
Löschen-Recht. Zellen ohne passendes Recht für die jeweilige Zeile (z.B.
"D" bei Switche/Zugangsdaten) werden als ausgegraute, nicht anklickbare
Checkbox dargestellt statt zu fehlen — dadurch bleiben alle Spalten
bündig untereinander. readonly=true zeigt nur den aktuellen Stand
(Admin/Systemgruppen bzw. fehlendes groups.edit). Die JS-Funktion
applyPermissionGating() sperrt Kind-Rechte, solange das Bereich-Lesen
(Kill-Switch) nicht gesetzt ist. Mehrere Bereiche stehen per Flexbox
nebeneinander (siehe .permission-groups-row), damit die ganze
Rechteübersicht einer Gruppe in einer kompakten Zeile Platz hat. #}
{% macro permission_table(group, group_key, checked_keys, readonly) %}
{% set row_types = group_row_types[group_key] %}
<div class="permission-group-col">
<div style="overflow-x:auto;">
<table class="permission-table">
<thead>
<tr>
<th class="permission-group-header-cell">
<label class="permission-group-toggle">
<input type="checkbox" name="permissions" value="{{ group['view_key'] }}"
title="{{ permission_labels.get(group['view_key'], group['label']) }}"
{% if group['view_key'] in checked_keys %}checked{% endif %}
{% if readonly %}disabled{% endif %}
class="permission-area-toggle-cb">
<span class="permission-group-name">{{ group['label'] }}</span>
</label>
</th>
{% for row_key, row_letter, row_label in row_types %}
<th title="{{ row_label }}">{{ row_letter }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for child_key, child in group['children'].items() %}
<tr>
<td class="permission-row-label">{{ child['label'] }}</td>
{% for row_key, row_letter, row_label in row_types %}
{% set perm_key = child['rows'].get(row_key) %}
<td>
{% if perm_key %}
<input type="checkbox" name="permissions" value="{{ perm_key }}"
title="{{ permission_labels.get(perm_key, perm_key) }}"
{% if perm_key in checked_keys %}checked{% endif %}
{% if readonly %}disabled{% endif %}
class="permission-child-cb">
{% else %}
<input type="checkbox" disabled class="permission-cb-na" tabindex="-1">
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endmacro %}
{# compact=true (Gruppen-Dropdown auf der Hauptseite) verkleinert Zellen-
Padding/Abstände gegenüber dem "Neue Gruppe"-Modal (compact=false, bleibt
unverändert) — gleicher Aufbau, nur enger, da im Dropdown mehr Breite
zur Verfügung steht als im schmalen Modal und es sonst auseinandergezogen wirkt. #}
{% macro permission_tree(checked_keys, readonly, compact=false) %}
<div class="permission-groups-row{{ ' permission-groups-row--compact' if compact }}">
{% for group_key, group in permission_catalog.items() %}
{{ permission_table(group, group_key, checked_keys, readonly) }}
{% endfor %}
</div>
<div class="permission-legend">
<strong>R</strong> = Read (Lesen) · <strong>W</strong> = Write (Anlegen) ·
<strong>E</strong> = Edit (Ändern, inkl. Löschen — bei Im-/Export: Import ausführen) ·
<strong>D</strong> = Dashboard (PoE-Neustart über das Dashboard-Popup, nur bei Clients) ·
<strong>X</strong> = Export (nur bei Im-/Export — Export-Datei enthält Passwörter im Klartext)
</div>
{% endmacro %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Gruppen</h2>
<div class="hint">
Rechte je Gruppe granular vergeben, Mitgliedschaft in mehreren Gruppen addiert sich.
„Admin“ und „Benutzer“ sind feste Systemgruppen. Legende direkt bei den Rechten.
</div>
</div>
{% if current_user.has_permission('groups.create') %}
<button type="button" class="btn btn-primary" data-open-modal="addGroupModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neue Gruppe
</button>
{% endif %}
</div>
<div class="table-wrap">
<div class="table-toolbar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input type="text" id="groupSearch" placeholder="Gruppen durchsuchen…" oninput="filterGroupsTable()">
</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table" id="groupsTable" data-sortable>
<thead><tr>
<th data-sort-key="name">Gruppe</th>
<th data-sort-key="members">Mitglieder</th>
<th style="width:1%;">Aktionen</th>
</tr></thead>
<!-- Virtuelle "Admin"-Gruppe: Rechte sind fix (alles), Mitgliedschaft
wird direkt über is_admin gesteuert. Bleibt beim Sortieren immer
oben (eigenes <tbody data-sort-pinned>). -->
<tbody data-sort-pinned>
<tr>
<td class="cell-name">Admin <span class="pill admin">Systemrolle</span></td>
<td class="text-dim">{{ admin_virtual_group.member_names|length }}</td>
<td>
<div class="row-actions">
<button class="icon-btn" title="Rechte anzeigen" onclick="toggleDetail('detail-admin')">
<svg id="chev-admin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
</button>
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="adminMembersModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/></svg>
</button>
</div>
</td>
</tr>
<tr class="group-detail-row hidden" id="detail-admin">
<td colspan="3">
{{ permission_tree(admin_virtual_group.permissions, true, true) }}
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Admins dürfen immer alles — diese Rechte sind fest und nicht änderbar.</p>
</td>
</tr>
</tbody>
{% for g in groups %}
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
<!-- Jede Gruppe (Haupt- + Detail-Zeile) in einem eigenen <tbody>, damit
die Sortierung beide Zeilen gemeinsam verschiebt. -->
<tbody data-sort-name="{{ g.name|lower }}" data-sort-members="{{ g.member_names|length }}">
<tr>
<td class="cell-name">
{{ g.name }}
{% if g.is_system %}<span class="pill user" style="white-space:nowrap;">Standard</span>{% endif %}
</td>
<td class="text-dim">{{ g.member_names|length }}</td>
<td>
<div class="row-actions">
<button class="icon-btn" title="Rechte anzeigen{{ '/bearbeiten' if can_edit_this else '' }}" onclick="toggleDetail('detail-{{ g.id }}')">
<svg id="chev-{{ g.id }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
</button>
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="membersModal{{ loop.index }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/></svg>
</button>
{% if current_user.has_permission('groups.edit') and not g.is_default and not g.is_system %}
<form method="post" data-confirm="Gruppe „{{ g.name }}“ wirklich löschen? Mitglieder verlieren die zugehörigen Rechte.">
<input type="hidden" name="delete_group" value="{{ g.id }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% set can_unlock_system = g.is_system and current_user.is_admin %}
<tr class="group-detail-row hidden" id="detail-{{ g.id }}">
<td colspan="3">
{% if can_edit_this %}
<form method="post">
<input type="hidden" name="save_group" value="1">
<input type="hidden" name="permissions_submitted" value="1">
<input type="hidden" name="group_id" value="{{ g.id }}">
<input type="hidden" name="name" value="{{ g.name }}">
{{ permission_tree(g.permissions, false, true) }}
<div class="flex" style="justify-content:flex-end; margin-top:16px;">
<button type="submit" class="btn btn-primary btn-sm">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Rechte speichern
</button>
</div>
</form>
{% elif can_unlock_system %}
<div id="readonly-{{ g.id }}">
{{ permission_tree(g.permissions, true, true) }}
<div class="flex" style="justify-content:space-between; align-items:center; margin-top:12px;">
<p class="text-faint" style="font-size:11.5px; margin:0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind normalerweise fest.</p>
<button type="button" class="btn btn-secondary btn-sm" onclick="unlockSystemGroup({{ g.id }})">Freischalten</button>
</div>
</div>
<form method="post" class="hidden" id="unlock-{{ g.id }}">
<input type="hidden" name="save_group" value="1">
<input type="hidden" name="permissions_submitted" value="1">
<input type="hidden" name="unlock_system_group" value="1">
<input type="hidden" name="group_id" value="{{ g.id }}">
<input type="hidden" name="name" value="{{ g.name }}">
{{ permission_tree(g.permissions, false, true) }}
<p class="text-faint" style="font-size:11.5px; margin:12px 0;">
⚠ Diese Gruppe ist die Standardgruppe für neue Benutzer (auch neu angelegte AD/LDAP-Konten). Zu restriktive
Rechte hier können den Erst-Login neuer Konten einschränken.
</p>
<div class="flex" style="justify-content:flex-end;">
<button type="submit" class="btn btn-primary btn-sm">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Rechte speichern
</button>
</div>
</form>
{% else %}
{{ permission_tree(g.permissions, true, true) }}
{% if g.is_system %}
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind fest und nicht änderbar.</p>
{% endif %}
{% endif %}
</td>
</tr>
</tbody>
{% else %}
<tbody data-sort-pinned>
<tr class="empty-row"><td colspan="3">Noch keine weiteren Gruppen angelegt.</td></tr>
</tbody>
{% endfor %}
</table>
</div>
</div>
<!-- Modal: Admin-Mitglieder verwalten -->
<div class="modal-overlay" id="adminMembersModal">
<div class="modal" style="max-width:380px;">
<form method="post">
<input type="hidden" name="assign_admins" value="1">
<div class="modal-header">
<h3>Admin-Mitglieder</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<p class="text-faint" style="font-size:11.5px; margin:0 0 12px;">Mindestens ein Admin muss bestehen bleiben.</p>
<div class="check-list" style="max-height:320px; overflow-y:auto; padding-right:4px;">
{% for u in all_users_all %}
<label class="check-row">
<input type="checkbox" name="members" value="{{ u['id'] }}" {% if u['is_admin'] %}checked{% endif %}>
{{ u['username'] }}
</label>
{% endfor %}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
<!-- Modals: Mitglieder pro Gruppe verwalten -->
{% for g in groups %}
<div class="modal-overlay" id="membersModal{{ loop.index }}">
<div class="modal" style="max-width:380px;">
<form method="post">
<input type="hidden" name="save_group" value="1">
<input type="hidden" name="members_submitted" value="1">
<input type="hidden" name="group_id" value="{{ g.id }}">
<input type="hidden" name="name" value="{{ g.name }}">
<div class="modal-header">
<h3>Mitglieder — {{ g.name }}</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="check-list" style="max-height:320px; overflow-y:auto; padding-right:4px;">
{% for u in all_users %}
<label class="check-row">
<input type="checkbox" name="members" value="{{ u['id'] }}" {% if u['id'] in g.members %}checked{% endif %}>
{{ u['username'] }}
</label>
{% else %}
<p class="text-faint" style="font-size:12px;">Keine Nicht-Admin-Benutzer vorhanden.</p>
{% endfor %}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
{% endfor %}
<!-- Modal: Neue Gruppe — Rechte direkt beim Anlegen setzbar, Mitglieder
werden danach über die Gruppentabelle zugeordnet (die Gruppe muss
dafür erst existieren). -->
<div class="modal-overlay" id="addGroupModal">
<div class="modal" style="max-width:1000px;">
<form method="post">
<div class="modal-header">
<h3>Neue Gruppe</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<input type="hidden" name="add_group" value="1">
<div class="field">
<label>Name</label>
<input type="text" name="name" required placeholder="z.B. Facility-Team">
<div class="field-hint">Mitglieder werden danach über die Gruppentabelle zugeordnet.</div>
</div>
{{ permission_tree([], false) }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Anlegen</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Filtert ganze <tbody>-Blöcke (Haupt- + Detail-Zeile je Gruppe gemeinsam,
// analog zur Sortierung) statt einzelner <tr> -- die gepinnte Admin-Gruppe
// (data-sort-pinned) bleibt dabei immer sichtbar, unabhängig vom Suchbegriff.
function filterGroupsTable() {
const q = document.getElementById("groupSearch").value.trim().toLowerCase();
document.querySelectorAll("#groupsTable tbody").forEach(tbody => {
if (tbody.hasAttribute("data-sort-pinned")) return;
tbody.style.display = tbody.innerText.toLowerCase().includes(q) ? "" : "none";
});
}
// Freischalten der Systemgruppe "Benutzer": zeigt statt der Nur-Lese-Ansicht
// das editierbare Formular (inkl. unlock_system_group=1) -- serverseitig
// erneut geprüft (admin + Flag), das hier ist nur die UI-Bestätigung.
function unlockSystemGroup(id) {
window.confirmAction(
"Rechte der Standardgruppe „Benutzer“ wirklich bearbeiten? Diese Gruppe ist der Login-Fallback für neue Benutzer (auch neue AD/LDAP-Konten) — zu restriktive Rechte können deren Erst-Login einschränken.",
() => {
document.getElementById("readonly-" + id).classList.add("hidden");
document.getElementById("unlock-" + id).classList.remove("hidden");
},
"Standardgruppe freischalten?"
);
}
function toggleDetail(id) {
const row = document.getElementById(id);
if (!row) return;
row.classList.toggle("hidden");
const chev = document.getElementById(id.replace("detail-", "chev-"));
if (chev) chev.style.transform = row.classList.contains("hidden") ? "" : "rotate(180deg)";
}
// Kind-Rechte einer Kategorie sind erst vergebbar, wenn das übergeordnete
// "Bereich anzeigen"-Recht (Kill-Switch, erste Spalte/L-Zeile) gesetzt ist —
// spiegelt serverseitig User.has_permission() (PERMISSION_PARENT_GROUP).
// Bearbeitbare Tabellen bekommen einen Live-Listener, Nur-Lese-Tabellen
// (Admin/Systemgruppen) bleiben unangetastet, deren Checkboxen sind ohnehin
// alle disabled.
function applyPermissionGating() {
document.querySelectorAll(".permission-group-col").forEach(function (area) {
const toggle = area.querySelector(".permission-area-toggle-cb");
if (!toggle || toggle.disabled) return;
const tbody = area.querySelector("tbody");
const children = area.querySelectorAll(".permission-child-cb");
const sync = function () {
children.forEach(function (cb) {
cb.disabled = !toggle.checked;
if (!toggle.checked) cb.checked = false;
});
// Zusätzlich zum disabled-Attribut (das der Browser nur dezent
// abblendet) sichtbar ausgrauen, wie gefordert — sonst fällt
// "Bereich gesperrt" auf den ersten Blick kaum auf.
if (tbody) tbody.classList.toggle("permission-locked", !toggle.checked);
};
toggle.addEventListener("change", sync);
sync();
});
}
// R (erste Spalte/"Lesen") ist je Zeile Voraussetzung für W/E/D/X -- ein
// Recht ohne R wäre sonst über die UI nicht erreichbar (z.B. Nav-Link/Seite
// bleibt unsichtbar, obwohl das Kind-Recht technisch gesetzt ist). Abwahl
// von R nimmt deshalb automatisch auch die anderen Spalten dieser Zeile mit,
// Anwahl von W/E/D/X wählt automatisch R mit an.
function applyRowViewPrerequisite() {
document.querySelectorAll(".permission-table tbody tr").forEach(function (tr) {
const boxes = Array.from(tr.querySelectorAll(".permission-child-cb"));
if (boxes.length < 2) return;
const viewBox = boxes[0];
const restBoxes = boxes.slice(1);
restBoxes.forEach(function (cb) {
cb.addEventListener("change", function () {
if (cb.checked && !viewBox.checked && !viewBox.disabled) {
viewBox.checked = true;
}
});
});
viewBox.addEventListener("change", function () {
if (!viewBox.checked) {
restBoxes.forEach(function (cb) { cb.checked = false; });
}
});
});
}
document.addEventListener("DOMContentLoaded", applyPermissionGating);
document.addEventListener("DOMContentLoaded", applyRowViewPrerequisite);
</script>
{% endblock %}
+327
View File
@@ -0,0 +1,327 @@
{% extends "base.html" %}
{% set active_page = "index" %}
{% block page_title %}Dashboard{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ device_count }} Geräte{{ " im Bestand" if current_user.is_authenticated else "" }}</div>{% endblock %}
{% block content %}
<div class="search-input" style="max-width:320px; margin-bottom:18px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input type="text" id="tileSearch" placeholder="Gerät suchen…">
</div>
{% include "_dashboard_tiles.html" %}
{% if not current_user.is_authenticated %}
<p class="text-faint mt-3" style="font-size:12px;">
Nur Lese-Ansicht — deaktivierte Geräte werden nicht angezeigt.
<a href="{{ url_for('login') }}" style="color:var(--accent-strong); font-weight:600;">Anmelden</a>,
um alle Geräte zu verwalten oder einen PoE-Neustart auszulösen.
</p>
{% endif %}
{% if current_user.is_authenticated %}
<!-- Device detail modal -->
<div class="modal-overlay" id="deviceModal">
<div class="modal">
<div class="modal-header">
<h3 id="deviceModalTitle">Gerät</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="detail-list">
<div class="detail-row"><span class="k">IP-Adresse</span><span class="v mono" id="deviceIp">-</span></div>
<div class="detail-row"><span class="k">Switch</span><span class="v" id="deviceSwitch">-</span></div>
<div class="detail-row"><span class="k">Port</span><span class="v" id="devicePort">-</span></div>
<div class="detail-row"><span class="k">Status</span><span class="v" id="deviceStatus">-</span></div>
<div class="detail-row"><span class="k">Letzte Prüfung</span><span class="v" id="deviceChecked">-</span></div>
</div>
</div>
<div class="modal-footer">
{% if current_user.has_permission('devices.edit') %}
<button type="button" class="btn btn-success" id="activateButton" style="display:none;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5L20 7"/></svg>
Aktivieren
</button>
{% endif %}
{% if current_user.has_permission('devices.restart') %}
<button type="button" class="btn btn-primary" id="restartButton" style="display:none;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
Neustarten
</button>
{% endif %}
<!-- Schließen bewusst als letztes Element im DOM: modal-footer nutzt
justify-content:flex-end, damit landet der zuletzt gerenderte
Button ganz rechts — Schließen soll unabhängig davon, welche
Aktions-Buttons gerade sichtbar sind, immer rechts stehen. -->
<button type="button" class="btn btn-secondary" data-close-modal>Schließen</button>
</div>
</div>
</div>
{% if current_user.has_permission('devices.restart') %}
<!-- Restart confirm modal -->
<div class="modal-overlay" id="restartModal">
<div class="modal" style="max-width:400px;">
<div class="modal-header">
<h3 id="restartTitle">Neustart</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<p class="text-dim">Der Neustart erfolgt durch einen kurzen PoE-Reset. Das Gerät wird für wenige Sekunden vom Netzwerk getrennt.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="button" class="btn btn-primary" id="confirmRestart" style="background:var(--warning); color:#241a00;">Neustarten</button>
</div>
</div>
</div>
{% endif %}
{% endif %}
{% endblock %}
{% block scripts %}
<script>
document.addEventListener("DOMContentLoaded", () => {
const isAuthenticated = {{ "true" if current_user.is_authenticated else "false" }};
let selectedCard = null, selectedMac = null, selectedName = null;
const restartButton = document.getElementById("restartButton");
const activateButton = document.getElementById("activateButton");
const searchInput = document.getElementById("tileSearch");
// Status-Filter über die Stat-Kacheln (Online/Offline/Deaktiviert/
// Gesamt) — null = kein Filter aktiv ("Gesamt"). Zusammen mit dem
// Suchfilter kombiniert in applyFilters(), damit beide gleichzeitig
// greifen können (z.B. "offline" + Namenssuche).
let activeStatusFilter = null;
// Welche Abschnitte (per data-status) manuell eingeklappt wurden — per
// Klick auf den Abschnittstitel, unabhängig vom Status-Filter. Bleibt
// über Live-Updates hinweg erhalten (siehe applyCollapseState()).
const collapsedSections = new Set();
// Kombinierter Filter über alle Abschnitte hinweg: ein Abschnitt wird
// komplett ausgeblendet, wenn er nicht zum Status-Filter passt ODER
// keine seiner Kacheln mehr zur Namenssuche passt. Wird nach jedem
// Live-Update erneut angewendet, damit aktive Filter erhalten bleiben
// (die alten DOM-Knoten werden beim Austausch verworfen).
function applyFilters() {
const q = (searchInput ? searchInput.value : "").trim().toLowerCase();
let anyVisible = false;
document.querySelectorAll(".dash-section").forEach(section => {
const statusMatches = !activeStatusFilter || section.dataset.status === activeStatusFilter;
let sectionHasMatch = false;
section.querySelectorAll(".device-card").forEach(card => {
const match = statusMatches && card.dataset.name.toLowerCase().includes(q);
card.classList.toggle("hidden", !match);
if (match) sectionHasMatch = true;
});
section.classList.toggle("hidden", !sectionHasMatch);
if (sectionHasMatch) anyVisible = true;
});
const noResults = document.getElementById("noSearchResults");
if (noResults) noResults.classList.toggle("hidden", anyVisible || (q === "" && !activeStatusFilter));
// Aktive Stat-Kachel synchron halten — läuft hier statt nur im
// Klick-Handler, damit der Zustand auch nach einem Live-Update
// (frische, neu eingefügte Stat-Kacheln) sofort wieder stimmt.
document.querySelectorAll(".stat-card[data-status-filter]").forEach(c => {
const isTotal = c.dataset.statusFilter === "";
const active = activeStatusFilter === null ? isTotal : c.dataset.statusFilter === activeStatusFilter;
c.classList.toggle("active", active);
});
applyCollapseState();
}
if (searchInput) searchInput.addEventListener("input", applyFilters);
// Ein-/Ausgeklappt-Zustand je Abschnitt anhand von collapsedSections
// anwenden — separat von der Sichtbarkeit oben: "hidden" blendet den
// ganzen Abschnitt (inkl. Titel) aus, "collapsed" versteckt nur das
// Kachel-Raster darunter, der Titel bleibt klickbar sichtbar.
function applyCollapseState() {
document.querySelectorAll(".dash-section").forEach(section => {
section.classList.toggle("collapsed", collapsedSections.has(section.dataset.status));
});
}
// Stat-Kacheln (Online/Offline/Deaktiviert/Gesamt): Klick filtert das
// Dashboard auf genau diesen Status; erneuter Klick auf die bereits
// aktive Kachel (oder auf "Gesamt") hebt den Filter wieder auf.
function bindStatCards() {
document.querySelectorAll(".stat-card[data-status-filter]").forEach(card => {
card.addEventListener("click", function () {
const key = this.dataset.statusFilter;
activeStatusFilter = (!key || activeStatusFilter === key) ? null : key;
applyFilters();
});
});
}
// Abschnittstitel (z.B. "Offline"): Klick klappt nur diesen Abschnitt
// ein/aus, unabhängig vom Status-Filter oben.
function bindSectionToggles() {
document.querySelectorAll(".dash-section-title").forEach(title => {
title.addEventListener("click", function () {
const key = this.closest(".dash-section").dataset.status;
if (collapsedSections.has(key)) collapsedSections.delete(key); else collapsedSections.add(key);
applyCollapseState();
});
});
}
// Klick-Verhalten der Kacheln (Detail-Modal, Neustart/Aktivieren) — wird
// nach jedem Live-Update erneut auf die frisch eingefügten Kacheln
// angewendet, da deren alte DOM-Knoten beim Austausch verworfen werden.
function bindDeviceCards() {
if (!isAuthenticated) return;
document.querySelectorAll(".device-card").forEach(card => {
const mac = card.dataset.mac;
const restartKey = "restart_" + mac;
if (sessionStorage.getItem(restartKey)) {
const pill = card.querySelector(".pill");
if (pill && pill.innerText.trim().toLowerCase() === "online") {
sessionStorage.removeItem(restartKey);
} else if (pill) {
pill.innerHTML = '<span class="spinner"></span> Restarting…';
}
}
card.addEventListener("click", function () {
if (sessionStorage.getItem("restart_" + this.dataset.mac)) return;
selectedCard = this;
selectedMac = this.dataset.mac;
selectedName = this.dataset.name;
const isActive = this.dataset.active != "0";
document.getElementById("deviceModalTitle").innerText = this.dataset.name;
document.getElementById("deviceIp").innerText = this.dataset.ip || "-";
document.getElementById("deviceSwitch").innerText = this.dataset.switch || "-";
document.getElementById("devicePort").innerText = this.dataset.port || "-";
document.getElementById("deviceChecked").innerText = this.dataset.checked || "-";
const pill = this.querySelector(".pill");
document.getElementById("deviceStatus").innerText = pill ? pill.innerText.trim() : "-";
if (restartButton) {
// Ohne zugewiesenen Switch + Port kann poe.sh keinen PoE-Reset
// auslösen (siehe restart_device()/poe.sh) — der Button wird für
// diese Geräte komplett ausgeblendet statt nur ausgegraut, damit
// kein Neustart vorgegaukelt wird, der tatsächlich nichts bewirkt.
const hasSwitchAndPort = this.dataset.switch !== "-" && this.dataset.port !== "-";
restartButton.style.display = (isActive && hasSwitchAndPort) ? "" : "none";
restartButton.disabled = false;
restartButton.title = "";
}
if (activateButton) activateButton.style.display = isActive ? "none" : "";
PoeUI.openModal("deviceModal");
});
});
}
bindDeviceCards();
bindStatCards();
bindSectionToggles();
applyFilters();
// Live-Update der Dashboard-Kacheln ohne vollen Seiten-Reload: tauscht
// nur den #dashboard-tiles-Container gegen das AJAX-Partial aus (siehe
// /dashboard/tiles) und hält dabei Suchfilter, Scroll-Position und
// Sidebar-Zustand unangetastet. Läuft für an- und abgemeldete Ansicht.
let refreshTimer = null;
function scheduleNextRefresh(overrideMs) {
if (refreshTimer) clearTimeout(refreshTimer);
if (overrideMs) { refreshTimer = setTimeout(refreshDashboardTiles, overrideMs); return; }
const pill = document.getElementById("global-timer-pill");
const intervalMs = pill ? parseInt(pill.dataset.intervalMs, 10) : NaN;
if (!intervalMs) return;
const lastRunMs = parseInt(pill.dataset.lastRunMs, 10);
// + kleiner Puffer, bis poe.sh die neue Zeile tatsächlich geschrieben hat.
let waitMs = intervalMs + 1500;
if (!isNaN(lastRunMs) && lastRunMs > 0) {
const elapsed = Date.now() - lastRunMs;
waitMs = (intervalMs - (((elapsed % intervalMs) + intervalMs) % intervalMs)) + 1500;
}
refreshTimer = setTimeout(refreshDashboardTiles, waitMs);
}
function refreshDashboardTiles() {
fetch("{{ url_for('dashboard_tiles') }}")
.then(r => r.text())
.then(html => {
const container = document.getElementById("dashboard-tiles");
if (!container) return;
const temp = document.createElement("div");
temp.innerHTML = html;
const fresh = temp.querySelector("#dashboard-tiles");
if (!fresh) return;
container.replaceWith(fresh);
// Globalen Topbar-Timer mit dem tatsächlichen Zeitpunkt
// dieses Durchlaufs synchronisieren (initCheckTimer() in
// app.js liest das Attribut bei jedem Tick neu ein).
const pill = document.getElementById("global-timer-pill");
if (pill) pill.dataset.lastRunMs = fresh.dataset.lastRunMs || "";
bindDeviceCards();
bindStatCards();
bindSectionToggles();
applyFilters();
scheduleNextRefresh();
})
.catch(() => scheduleNextRefresh(15000));
}
scheduleNextRefresh();
document.addEventListener("poe:check-triggered", refreshDashboardTiles);
if (!isAuthenticated) {
return;
}
if (restartButton) {
restartButton.addEventListener("click", function () {
PoeUI.closeModal(document.getElementById("deviceModal"));
document.getElementById("restartTitle").innerText = selectedName + " neu starten?";
PoeUI.openModal("restartModal");
});
}
const confirmRestart = document.getElementById("confirmRestart");
if (confirmRestart) {
confirmRestart.addEventListener("click", function () {
const button = this;
button.disabled = true;
fetch("/restart/" + encodeURIComponent(selectedMac), { method: "POST" })
.then(r => r.json())
.then(data => {
button.disabled = false;
if (!data.success) {
showToast(data.message || "Neustart konnte nicht gestartet werden.", "danger");
return;
}
PoeUI.closeModal(document.getElementById("restartModal"));
PoeUI.closeModal(document.getElementById("deviceModal"));
sessionStorage.setItem("restart_" + selectedMac, "1");
const pill = selectedCard.querySelector(".pill");
if (pill) pill.innerHTML = '<span class="spinner"></span> Restarting…';
showToast(`Neustart von ${data.device} gestartet.`, "success");
})
.catch(err => { button.disabled = false; showToast("Fehler beim Starten des Neustarts.", "danger"); });
});
}
if (activateButton) {
activateButton.addEventListener("click", function () {
const button = this;
button.disabled = true;
fetch("/devices/toggle/" + encodeURIComponent(selectedMac), { method: "POST" })
.then(r => r.json())
.then(data => {
button.disabled = false;
if (!data.success) {
showToast(data.msg || "Aktivieren fehlgeschlagen.", "danger");
return;
}
PoeUI.closeModal(document.getElementById("deviceModal"));
showToast(data.msg, "success");
refreshDashboardTiles();
})
.catch(() => { button.disabled = false; showToast("Fehler beim Aktivieren.", "danger"); });
});
}
});
</script>
{% endblock %}
+51
View File
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login · TESM</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='images/icon-dark.svg') }}" id="app-favicon">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<div class="login-page">
<div class="login-branding">
<img src="{{ url_for('static', filename='images/logo-dark-subline.svg') }}" alt="TESM" id="login-bg-logo">
</div>
<div class="login-card">
<div class="login-logo">
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="WiS">
</div>
<div style="display:flex; justify-content:center; margin-bottom:4px;">
<img id="sidebar-logo" src="{{ url_for('static', filename='images/logo-dark.svg') }}" alt="TESM" style="height:32px; width:auto;">
</div>
<p class="login-sub">Melde dich an, um fortzufahren</p>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="field" style="margin-bottom:18px;">
{% for message in messages %}
<div class="toast danger" style="position:static; animation:none;">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="post" data-no-unsaved-guard>
<div class="field">
<label for="username">Benutzername</label>
<input type="text" id="username" name="username" autocomplete="username" required autofocus>
</div>
<div class="field">
<label for="password">Passwort</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary btn-block" style="margin-top:6px;">Anmelden</button>
</form>
</div>
</div>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
</body>
</html>
+76
View File
@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% set active_page = "logs" %}
{% block page_title %}Live{% endblock %}
{% block page_sub %}<div class="topbar-sub" data-log-name>{{ log_name or "kein Logfile" }}</div>{% endblock %}
{% block content %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Live-Log</h2>
<div class="hint">Laufende Erreichbarkeitsprüfung von poe.sh, farblich markiert (online/offline).</div>
</div>
<button id="refresh-btn" class="btn btn-secondary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
Aktualisieren
</button>
</div>
<div class="log-shell">
<div class="log-toolbar">
<div class="log-dots"><span></span><span></span><span></span></div>
<span class="text-faint mono" style="font-size:11.5px;" data-log-name>{{ log_name or "" }}</span>
</div>
<div id="log-box">{{ log_content or "Keine Logfiles gefunden." }}</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function colorizeLine(line) {
let cls = "";
if (line.includes(" ist erreichbar!")) cls = "online";
else if (line.includes(" ist nicht erreichbar!")) cls = "offline";
else if (line.startsWith("----")) cls = "sep";
// Neustart-bezogene Zeilen (manueller Neustart, automatischer PoE-Restart
// bei Ausfall) einheitlich orange markieren.
else if (line.toLowerCase().includes("manuell") || line.includes("PoE")) cls = "restart";
const span = document.createElement("span");
span.className = "log-line" + (cls ? " " + cls : "");
span.textContent = line;
return span;
}
document.addEventListener("DOMContentLoaded", () => {
const intervalMinutes = {{ global_check_interval | int }};
const intervalMilliseconds = intervalMinutes * 60 * 1000;
function renderLog(text, logName) {
const box = document.getElementById("log-box");
box.innerHTML = "";
const lines = text.split("\n");
lines.forEach((line, i) => {
box.appendChild(colorizeLine(line));
if (i < lines.length - 1) box.appendChild(document.createElement("br"));
});
box.scrollTop = box.scrollHeight;
// Dateiname mit anzeigen — ändert sich nach einem Service-Neustart
// (neues Logfile, z.B. durch "Jetzt prüfen") ohne Seiten-Reload.
if (logName) {
document.querySelectorAll("[data-log-name]").forEach((el) => { el.textContent = logName; });
}
}
function fetchLog() {
fetch("{{ url_for('get_log') }}")
.then(r => r.text().then((text) => renderLog(text, r.headers.get("X-Log-Name"))))
.catch(err => console.error(err));
}
document.getElementById("refresh-btn").addEventListener("click", fetchLog);
document.addEventListener("poe:check-triggered", fetchLog);
fetchLog();
if (intervalMilliseconds) setInterval(fetchLog, intervalMilliseconds);
});
</script>
{% endblock %}
+65
View File
@@ -0,0 +1,65 @@
{% extends "base.html" %}
{% set active_page = "logs" %}
{% block page_title %}Kea-DHCP{% endblock %}
{% block page_sub %}<div class="topbar-sub">Log-Ausgabe des kea-dhcp4-server-Dienstes</div>{% endblock %}
{% block content %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Kea-DHCP-Log</h2>
<div class="hint">Eigene Log-Datei des DHCP-Dienstes (siehe Systemeinstellungen → Logs für Rotation/Aufbewahrung).</div>
</div>
<button id="refresh-btn" class="btn btn-secondary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
Aktualisieren
</button>
</div>
<div class="log-shell">
<div class="log-toolbar">
<div class="log-dots"><span></span><span></span><span></span></div>
<span class="text-faint mono" style="font-size:11.5px;">kea-dhcp4.log</span>
</div>
<div id="log-box">{{ log_content or "Keine Logdatei gefunden." }}</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function colorizeLine(line) {
let cls = "";
// Kea-Zeilenformat: "<ts> <SEVERITY> [logger/pid.tid] MESSAGE_ID text".
// Grobe Einfärbung nach Schweregrad, ohne die Logik des Live-Logs
// (online/offline) hier künstlich nachzubilden.
if (/\s(FATAL|ERROR)\s/.test(line)) cls = "offline";
else if (/\sWARN\s/.test(line)) cls = "restart";
const span = document.createElement("span");
span.className = "log-line" + (cls ? " " + cls : "");
span.textContent = line;
return span;
}
document.addEventListener("DOMContentLoaded", () => {
function renderLog(text) {
const box = document.getElementById("log-box");
box.innerHTML = "";
const lines = text.split("\n");
lines.forEach((line, i) => {
box.appendChild(colorizeLine(line));
if (i < lines.length - 1) box.appendChild(document.createElement("br"));
});
box.scrollTop = box.scrollHeight;
}
function fetchLog() {
fetch("{{ url_for('get_kea_log') }}")
.then(r => r.text().then(renderLog))
.catch(err => console.error(err));
}
document.getElementById("refresh-btn").addEventListener("click", fetchLog);
fetchLog();
});
</script>
{% endblock %}
+161
View File
@@ -0,0 +1,161 @@
{% extends "base.html" %}
{% set active_page = "maintenance" %}
{% block page_title %}Wartung{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Linux-Client{{ 's' if devices|length != 1 else '' }} mit SSH-Zugangsdaten</div>{% endblock %}
{% block content %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Wartung (SSH)</h2>
<div class="hint">
Bulk-Update (<code>apt update &amp; upgrade -y</code>) und Neustart per SSH direkt auf dem Gerät —
unabhängig vom PoE-Neustart über den Switch (siehe Dashboard). Nur Geräte mit hinterlegten
SSH-Zugangsdaten der Kategorie „Linux-Client“ erscheinen hier.
</div>
</div>
</div>
{% if not devices %}
<p class="text-faint" style="font-size:12.5px;">
Noch keine Geräte für die Wartung konfiguriert. Lege unter
<a href="{{ url_for('devices') }}" style="color:var(--accent-strong); font-weight:600;">Clients</a>
SSH-Zugangsdaten der Kategorie „Linux-Client“ an einem Gerät an — es erscheint danach automatisch hier.
</p>
{% else %}
<form method="post" action="{{ url_for('maintenance_update') }}" id="updateForm">
<div class="table-wrap">
<div class="table-toolbar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input type="text" id="maintenanceSearch" placeholder="Geräte durchsuchen…" oninput="filterTable('maintenanceSearch','maintenanceTable')">
</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table" id="maintenanceTable" data-sortable>
<thead><tr>
{% if can_run %}<th style="width:1%;"><input type="checkbox" id="selectAll" title="Alle auswählen"></th>{% endif %}
<th data-sort-key="name">Name</th>
<th data-sort-key="ip">IP-Adresse</th>
<th data-sort-key="user">SSH-User</th>
<th data-sort-key="status">Status</th>
<th style="width:1%;">Aktionen</th>
</tr></thead>
<tbody>
{% for d in devices %}
{% set job = jobs.get(d['mac']) %}
<tr data-mac="{{ d['mac'] }}" data-sort-name="{{ d['name']|lower }}" data-sort-ip="{{ d['ip'] }}" data-sort-user="{{ d['cred_username']|lower }}">
{% if can_run %}
<td><input type="checkbox" name="macs" value="{{ d['mac'] }}" class="maint-check" {{ 'disabled' if not d['is_active'] else '' }}></td>
{% endif %}
<td class="cell-name">{{ d['name'] }}{% if not d['is_active'] %} <span class="text-faint" style="font-size:11px;">(deaktiviert)</span>{% endif %}</td>
<td class="mono">{{ d['ip'] }}:{{ d['ssh_port'] or 22 }}</td>
<td class="mono">{{ d['cred_username'] }}</td>
<td class="maint-status">
{% if job %}
{% if job.status == 'running' %}<span class="pill unknown"><span class="spinner"></span> {{ job.message }}</span>
{% elif job.status == 'success' %}<span class="pill online">{{ job.message }}</span>
{% else %}<span class="pill offline">{{ job.message }}</span>
{% endif %}
{% else %}
<span class="pill disabled">Noch keine Aktion</span>
{% endif %}
</td>
<td>
<div class="row-actions">
<button type="button" class="icon-btn" title="Ausgabe anzeigen/verbergen" onclick="toggleJobOutput('{{ d['mac'] }}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
{% if can_run %}
<form method="post" action="{{ url_for('maintenance_reboot') }}" data-confirm="{{ d['name'] }} ({{ d['ip'] }}) jetzt per SSH neu starten?">
<input type="hidden" name="macs" value="{{ d['mac'] }}">
<button type="submit" class="btn btn-sm btn-secondary">Neustart</button>
</form>
{% endif %}
</div>
</td>
</tr>
<tr class="job-output-row" id="joboutput-{{ d['mac'] }}" style="display:none;">
<td colspan="{{ 6 if can_run else 5 }}">
<pre class="job-output-pre">{{ job.output if job and job.output else 'Keine Ausgabe.' }}</pre>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if can_run %}
<div class="section-head" style="margin-top:16px;">
<div class="hint">Ausgewählte Geräte aktualisieren (nicht-interaktiv, unbeaufsichtigte Konfig-Rückfragen werden automatisch mit den bisherigen Werten beantwortet).</div>
<button type="submit" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
Update starten
</button>
</div>
{% endif %}
</form>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
document.getElementById("selectAll")?.addEventListener("change", function () {
document.querySelectorAll(".maint-check").forEach(function (cb) {
if (!cb.disabled) cb.checked = this.checked;
}, this);
});
function filterTable(inputId, tableId) {
const q = document.getElementById(inputId).value.trim().toLowerCase();
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
// Ausgabe-Zeile bleibt ausschließlich über toggleJobOutput() gesteuert,
// sonst könnte ein zufälliger Text-Treffer darin sie unabhängig vom
// dazugehörigen Geräte-Namen ein-/ausblenden.
if (row.classList.contains("empty-row") || row.classList.contains("job-output-row")) return;
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
});
}
function toggleJobOutput(mac) {
const row = document.getElementById("joboutput-" + mac);
if (row) row.style.display = (row.style.display === "none" || !row.style.display) ? "table-row" : "none";
}
function statusPillHtml(job) {
if (!job) return '<span class="pill disabled">Noch keine Aktion</span>';
const msg = job.message || "";
if (job.status === "running") return '<span class="pill unknown"><span class="spinner"></span> ' + msg + '</span>';
if (job.status === "success") return '<span class="pill online">' + msg + '</span>';
return '<span class="pill offline">' + msg + '</span>';
}
let maintPollTimer = null;
function pollMaintenanceStatus() {
fetch("{{ url_for('maintenance_status') }}")
.then(function (r) { return r.json(); })
.then(function (jobs) {
let anyRunning = false;
document.querySelectorAll("tr[data-mac]").forEach(function (row) {
const mac = row.dataset.mac;
const job = jobs[mac];
const cell = row.querySelector(".maint-status");
if (cell) cell.innerHTML = statusPillHtml(job);
const outRow = document.getElementById("joboutput-" + mac);
if (outRow && job) {
const pre = outRow.querySelector(".job-output-pre");
if (pre) pre.textContent = job.output || "Keine Ausgabe.";
}
if (job && job.status === "running") anyRunning = true;
});
clearTimeout(maintPollTimer);
maintPollTimer = setTimeout(pollMaintenanceStatus, anyRunning ? 2000 : 8000);
})
.catch(function () { maintPollTimer = setTimeout(pollMaintenanceStatus, 8000); });
}
pollMaintenanceStatus();
</script>
{% endblock %}
+214
View File
@@ -0,0 +1,214 @@
{% extends "base.html" %}
{% set active_page = "papierkorb" %}
{% block page_title %}Papierkorb{% endblock %}
{% block page_sub %}<div class="topbar-sub">Gelöschte Clients, Switche, Zugangsdaten, Benutzer und Gruppen</div>{% endblock %}
{% block content %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Papierkorb</h2>
<div class="hint">
Gelöschte Clients, Switche, Zugangsdaten, Benutzer und Gruppen — werden nach
{{ trash_retention_days }} Tag{{ 'en' if trash_retention_days != 1 else '' }} automatisch endgültig gelöscht
(einstellbar unter Systemeinstellungen). AD/LDAP-Benutzer landen nie hier, da sie sich bei jedem gültigen
Login automatisch neu anlegen.
</div>
</div>
</div>
<div style="display:flex; flex-direction:column; gap:16px;">
{% if current_user.has_permission('devices.view') %}
<div class="card card-pad">
<h3 style="font-size:14px; margin:0 0 12px;">Clients ({{ trash.devices|length }})</h3>
{% if trash.devices %}
<div class="table-wrap"><div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Name</th><th>IP</th><th>Gelöscht am</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
{% for d in trash.devices %}
<tr>
<td class="cell-name">{{ d['name'] }}</td>
<td class="mono">{{ d['ip'] }}</td>
<td class="text-faint" style="font-size:12px;">{{ d['deleted_at'] }}</td>
<td>
{% if current_user.has_permission('devices.edit') %}
<div class="row-actions">
<form method="post" action="{{ url_for('restore_device', mac=d['mac']) }}">
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
</form>
<form method="post" action="{{ url_for('purge_device', mac=d['mac']) }}" data-confirm="„{{ d['name'] }}“ endgültig löschen? Das kann nicht rückgängig gemacht werden.">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Endgültig löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div></div>
{% else %}
<p class="text-faint" style="font-size:12px;">Papierkorb ist leer.</p>
{% endif %}
</div>
{% endif %}
{% if current_user.has_permission('switches.view') %}
<div class="card card-pad">
<h3 style="font-size:14px; margin:0 0 12px;">Switche ({{ trash.switches|length }})</h3>
{% if trash.switches %}
<div class="table-wrap"><div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Hostname</th><th>IP</th><th>Gelöscht am</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
{% for s in trash.switches %}
<tr>
<td class="cell-name">{{ s['hostname'] }}</td>
<td class="mono">{{ s['ip'] }}</td>
<td class="text-faint" style="font-size:12px;">{{ s['deleted_at'] }}</td>
<td>
{% if current_user.has_permission('switches.edit') %}
<div class="row-actions">
<form method="post" action="{{ url_for('restore_switch', hostname=s['hostname']) }}">
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
</form>
<form method="post" action="{{ url_for('purge_switch', hostname=s['hostname']) }}" data-confirm="„{{ s['hostname'] }}“ endgültig löschen? Das kann nicht rückgängig gemacht werden.">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Endgültig löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div></div>
{% else %}
<p class="text-faint" style="font-size:12px;">Papierkorb ist leer.</p>
{% endif %}
</div>
{% endif %}
{% if current_user.has_permission('credentials.view') %}
<div class="card card-pad">
<h3 style="font-size:14px; margin:0 0 12px;">Zugangsdaten ({{ trash.credentials|length }})</h3>
{% if trash.credentials %}
<div class="table-wrap"><div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Name</th><th>Username</th><th>Gelöscht am</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
{% for c in trash.credentials %}
<tr>
<td class="cell-name">{{ c['name'] }}</td>
<td class="mono">{{ c['username'] }}</td>
<td class="text-faint" style="font-size:12px;">{{ c['deleted_at'] }}</td>
<td>
{% if current_user.has_permission('credentials.edit') %}
<div class="row-actions">
<form method="post" action="{{ url_for('restore_credential', cred_id=c['id']) }}">
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
</form>
<form method="post" action="{{ url_for('purge_credential', cred_id=c['id']) }}" data-confirm="„{{ c['name'] }}“ endgültig löschen? Das kann nicht rückgängig gemacht werden.">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Endgültig löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div></div>
{% else %}
<p class="text-faint" style="font-size:12px;">Papierkorb ist leer.</p>
{% endif %}
</div>
{% endif %}
{% if current_user.has_permission('users.view') %}
<div class="card card-pad">
<h3 style="font-size:14px; margin:0 0 12px;">Benutzer ({{ trash.users|length }})</h3>
{% if trash.users %}
<div class="table-wrap"><div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Username</th><th>Name</th><th>Gelöscht am</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
{% for u in trash.users %}
{% set full_name = [u['first_name'], u['last_name']]|select|join(' ') %}
<tr>
<td class="cell-name">{{ u['username'] }}</td>
<td class="text-dim">{{ full_name or '—' }}</td>
<td class="text-faint" style="font-size:12px;">{{ u['deleted_at'] }}</td>
<td>
{% if current_user.has_permission('users.edit') %}
<div class="row-actions">
<form method="post" action="{{ url_for('restore_user', user_id=u['id']) }}">
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
</form>
<form method="post" action="{{ url_for('purge_user', user_id=u['id']) }}" data-confirm="„{{ u['username'] }}“ endgültig löschen? Das kann nicht rückgängig gemacht werden.">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Endgültig löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div></div>
{% else %}
<p class="text-faint" style="font-size:12px;">Papierkorb ist leer.</p>
{% endif %}
</div>
{% endif %}
{% if current_user.has_permission('groups.view') %}
<div class="card card-pad">
<h3 style="font-size:14px; margin:0 0 12px;">Gruppen ({{ trash.groups|length }})</h3>
{% if trash.groups %}
<div class="table-wrap"><div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Name</th><th>Gelöscht am</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
{% for g in trash.groups %}
<tr>
<td class="cell-name">{{ g['name'] }}</td>
<td class="text-faint" style="font-size:12px;">{{ g['deleted_at'] }}</td>
<td>
{% if current_user.has_permission('groups.edit') %}
<div class="row-actions">
<form method="post" action="{{ url_for('restore_group', group_id=g['id']) }}">
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
</form>
<form method="post" action="{{ url_for('purge_group', group_id=g['id']) }}" data-confirm="„{{ g['name'] }}“ endgültig löschen? Das kann nicht rückgängig gemacht werden.">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Endgültig löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div></div>
{% else %}
<p class="text-faint" style="font-size:12px;">Papierkorb ist leer.</p>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
+306
View File
@@ -0,0 +1,306 @@
{% extends "base.html" %}
{% set active_page = "settings_system" %}
{% block page_title %}Systemeinstellungen{% endblock %}
{% block page_sub %}<div class="topbar-sub">Prüfintervall und Netzwerkkonfiguration dieses Hosts</div>{% endblock %}
{% block content %}
<div class="settings-grid">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Host</h2>
<div class="hint">Name und Zeitzone dieses Hosts.</div>
</div>
</div>
{% if current_user.has_permission('settings_system.edit') %}
<form method="post">
<div class="field">
<label for="hostname">Hostname</label>
<input type="text" name="hostname" id="hostname" value="{{ current_hostname or '' }}" pattern="[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?" required>
</div>
<button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Hostname setzen
</button>
</form>
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
<form method="post">
<div class="field">
<label for="timezone">Zeitzone</label>
<select name="timezone" id="timezone" required>
{% for tz in timezones %}
<option value="{{ tz }}" {% if tz == current_timezone %}selected{% endif %}>{{ tz }}</option>
{% endfor %}
</select>
<div class="field-hint">Bestimmt die lokale Zeit in allen Logs und im Änderungsverlauf — wirkt sofort für diese laufende App-Instanz, ohne Dienst-Neustart.</div>
</div>
<button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Zeitzone setzen
</button>
</form>
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
<form method="post">
<div class="field">
<label for="interval">Prüfintervall (Minuten)</label>
<input type="number" name="interval" id="interval" value="{{ interval }}" min="1" required>
<div class="field-hint">Wie oft Geräte auf Erreichbarkeit geprüft werden. Der Hintergrund-Dienst (rpi-check.service) wird nach dem Speichern automatisch neu gestartet.</div>
</div>
<button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Speichern & Service neustarten
</button>
</form>
{% else %}
<div class="field">
<label>Hostname</label>
<input type="text" value="{{ current_hostname or '' }}" disabled>
</div>
<div class="field">
<label>Zeitzone</label>
<input type="text" value="{{ current_timezone or '' }}" disabled>
</div>
<div class="field">
<label>Prüfintervall (Minuten)</label>
<input type="number" value="{{ interval }}" disabled>
<div class="field-hint">Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“.</div>
</div>
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Netzwerkeinstellungen</h2>
<div class="hint">IP/DNS/DHCP-Umschaltung dieses Hosts (nicht der DHCP-<em>Server</em> unter „DHCP“).</div>
</div>
</div>
<div class="flex gap-2" style="align-items:center; margin-bottom:16px; flex-wrap:wrap;">
{% if net_backend == 'unknown' %}
<span class="pill unknown">Kein unterstütztes Backend erkannt</span>
{% else %}
<span class="pill online">{{ {'networkmanager': 'NetworkManager', 'dhcpcd': 'dhcpcd', 'netplan': 'netplan'}[net_backend] }}</span>
{% endif %}
{% if net_state %}
<span class="mono text-faint" style="font-size:12px;">
{{ net_interface }}
{% if net_state.ok %}— {{ net_state.ip }}/{{ net_state.prefix }}{% if net_state.gateway %}, Gateway {{ net_state.gateway }}{% endif %}{% endif %}
</span>
<span class="pill {{ 'user' if net_state.mode == 'static' else ('online' if net_state.mode == 'dhcp' else 'unknown') }}">
{{ {'static': 'Statisch', 'dhcp': 'DHCP', 'unknown': 'Modus unbekannt'}[net_state.mode] }}
</span>
{% endif %}
</div>
{% if net_state and net_state.dns %}
<div class="text-faint" style="font-size:12px; margin-bottom:16px;">Aktuelle DNS-Server: <span class="mono">{{ net_state.dns|join(', ') }}</span></div>
{% endif %}
{% if pending_network_token %}
<div class="card-pad" style="background:var(--warning-dim); border-radius:var(--radius-sm); margin-bottom:16px;">
<p style="margin:0 0 12px; font-size:13px;">
Neue Netzwerkkonfiguration wurde angewendet. Wenn diese Seite gerade noch lädt, funktioniert die Verbindung —
bitte bestätigen, bevor automatisch zurückgerollt wird (nach {{ net_revert_seconds }}s ohne Bestätigung).
</p>
<form method="post">
<input type="hidden" name="confirm_network" value="{{ pending_network_token }}">
<button type="submit" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Verbindung funktioniert — bestätigen
</button>
</form>
</div>
{% elif net_backend != 'unknown' and current_user.has_permission('settings_system.edit') %}
<form method="post" data-confirm="Netzwerkkonfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ net_revert_seconds }} Sekunden wiederhergestellt.">
<input type="hidden" name="apply_network" value="1">
<div class="field"><label>Interface</label>
<select name="net_interface">
{% for iface in net_interfaces %}
<option value="{{ iface }}" {% if iface == net_interface %}selected{% endif %}>{{ iface }}</option>
{% endfor %}
</select>
</div>
<div class="field"><label>Modus</label>
<select name="net_mode" id="netModeSelect" onchange="document.getElementById('netStaticFields').classList.toggle('hidden', this.value !== 'static')">
<option value="dhcp" {% if net_state.mode != 'static' %}selected{% endif %}>DHCP (automatisch)</option>
<option value="static" {% if net_state.mode == 'static' %}selected{% endif %}>Statisch</option>
</select>
</div>
<div id="netStaticFields" class="{{ 'hidden' if net_state.mode != 'static' }}">
<div class="field"><label>IP-Adresse</label>
<input type="text" name="net_ip" value="{{ net_state.ip if net_state.mode == 'static' else '' }}" placeholder="z.B. 192.168.1.50">
</div>
<div class="field"><label>Prefix (CIDR-Bits)</label>
<input type="number" name="net_prefix" min="1" max="32" value="{{ net_state.prefix if net_state.mode == 'static' else '' }}" placeholder="z.B. 24">
</div>
<div class="field"><label>Gateway</label>
<input type="text" name="net_gateway" value="{{ net_state.gateway if net_state.mode == 'static' else '' }}" placeholder="z.B. 192.168.1.1">
</div>
</div>
<div class="field"><label>DNS-Server</label>
<input type="text" name="net_dns" value="{{ net_state.dns|join(', ') if net_state and net_state.dns else '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8">
<div class="field-hint">Kommagetrennt. Leer lassen, um die per DHCP zugewiesenen DNS-Server zu verwenden.</div>
</div>
<button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Netzwerkkonfiguration anwenden
</button>
<p class="text-faint" style="font-size:11px; margin-top:10px;">
⚠ Kann die Erreichbarkeit dieses Hosts unterbrechen. Ohne Bestätigung wird automatisch nach {{ net_revert_seconds }}s zurückgerollt.
</p>
</form>
{% elif net_backend == 'unknown' %}
<p class="text-faint" style="font-size:12.5px;">
Weder NetworkManager, dhcpcd noch netplan/systemd-networkd aktiv erkannt — Netzwerkänderungen über diese Seite sind deaktiviert.
Bitte die Netzwerkkonfiguration dieses Hosts manuell vornehmen.
</p>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für Änderungen fehlt das Recht „Systemeinstellungen ändern“.</p>
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Logs</h2>
<div class="hint">Rotation & Aufbewahrung von Live-, Änderungs-, App- und Kea-DHCP-Log.</div>
</div>
</div>
{% if current_user.has_permission('settings_system.edit') %}
<form method="post">
<input type="hidden" name="save_log_rotation" value="1">
<div class="field">
<label for="log_rotation_interval">Rotations-Intervall</label>
<select name="log_rotation_interval" id="log_rotation_interval">
<option value="daily" {% if log_rotation_interval == "daily" %}selected{% endif %}>Täglich</option>
<option value="weekly" {% if log_rotation_interval == "weekly" %}selected{% endif %}>Wöchentlich</option>
<option value="monthly" {% if log_rotation_interval == "monthly" %}selected{% endif %}>Monatlich</option>
</select>
</div>
<div class="field">
<label for="log_rotation_keep">Aufbewahrung (Anzahl Rotationen)</label>
<input type="number" name="log_rotation_keep" id="log_rotation_keep" value="{{ log_rotation_keep }}" min="1" required>
<div class="field-hint">Standard: wöchentlich, 4 Rotationen (≈ 1 Monat Historie je Log).</div>
</div>
<button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Speichern
</button>
</form>
{% else %}
<div class="field">
<label>Rotations-Intervall</label>
<input type="text" value="{{ log_rotation_interval }}" disabled>
</div>
<div class="field">
<label>Aufbewahrung (Anzahl Rotationen)</label>
<input type="text" value="{{ log_rotation_keep }}" disabled>
</div>
{% endif %}
<div class="field-hint mono" style="margin-top:10px; font-size:11px; line-height:1.6;">
Live: {{ log_paths.live }}<br>
Änderungen: {{ log_paths.changes }}<br>
App: {{ log_paths.app }}<br>
Kea-DHCP: {{ log_paths.kea }}
</div>
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Papierkorb</h2>
<div class="hint">Gelöschte Clients, Switche, Zugangsdaten, Benutzer und Gruppen — Liste unter Geräte → Wartung.</div>
</div>
</div>
{% if current_user.has_permission('settings_system.edit') %}
<form method="post">
<div class="field">
<label for="trash_retention_days">Aufbewahrungsdauer (Tage)</label>
<input type="number" name="trash_retention_days" id="trash_retention_days" value="{{ trash_retention_days }}" min="1" required>
<div class="field-hint">Danach werden Papierkorb-Einträge automatisch unwiderruflich gelöscht (AD/LDAP-Benutzer sind nie im Papierkorb, da sie sich beim nächsten Login automatisch neu anlegen).</div>
</div>
<button type="submit" name="save_trash_retention" value="1" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Speichern
</button>
</form>
<form method="post" data-confirm="Alle bereits abgelaufenen Papierkorb-Einträge jetzt unwiderruflich löschen?" style="margin-top:10px;">
<input type="hidden" name="purge_trash_now" value="1">
<button type="submit" class="btn btn-sm" style="color:var(--danger); background:transparent; border-color:var(--danger-dim);">
Abgelaufene Einträge jetzt bereinigen
</button>
</form>
{% else %}
<div class="field">
<label>Aufbewahrungsdauer (Tage)</label>
<input type="number" value="{{ trash_retention_days }}" disabled>
</div>
{% endif %}
</div>
{% if current_user.is_admin %}
{% macro nav_order_buttons() %}
<div class="nav-order-actions">
<button type="button" class="icon-btn" title="Nach oben" onclick="moveNavItem(this,-1)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
</button>
<button type="button" class="icon-btn" title="Nach unten" onclick="moveNavItem(this,1)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12l7 7 7-7"/></svg>
</button>
</div>
{% endmacro %}
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Navbar-Reihenfolge</h2>
<div class="hint">Reihenfolge der Sidebar-Menüpunkte inkl. Unterpunkte — gilt für alle Benutzer.</div>
</div>
</div>
<form method="post" action="{{ url_for('save_nav_order') }}" id="navOrderForm">
<ul class="nav-order-list" id="navOrderList">
{% for item in full_nav_items %}
<li data-key="{{ item.key }}">
<div class="nav-order-row">
<span>{{ item.label }}</span>
{{ nav_order_buttons() }}
</div>
<input type="hidden" name="nav_order" value="{{ item.key }}">
{% if item.children %}
<ul class="nav-order-sublist">
{% for child in item.children %}
<li data-key="{{ child.key }}">
<div class="nav-order-row">
<span>{{ child.label }}</span>
{{ nav_order_buttons() }}
</div>
<input type="hidden" name="nav_child_order_{{ item.key }}" value="{{ child.key }}">
</li>
{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %}
</ul>
<button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Reihenfolge speichern
</button>
</form>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
function moveNavItem(btn, dir) {
const li = btn.closest("li");
const target = dir === -1 ? li.previousElementSibling : li.nextElementSibling;
if (!target) return;
if (dir === -1) li.parentNode.insertBefore(li, target);
else li.parentNode.insertBefore(target, li);
}
</script>
{% endblock %}
+635
View File
@@ -0,0 +1,635 @@
{% extends "base.html" %}
{% set active_page = "settings_dhcp" %}
{% set can_edit = current_user.has_permission('settings_dhcp.edit') %}
{% block page_title %}DHCP{% endblock %}
{% block page_sub %}<div class="topbar-sub">Reservierungen und eigene Options aus den Client-Stammdaten (Kea DHCP)</div>{% endblock %}
{% block content %}
<div class="settings-grid">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">DHCP-Server-Status</h2>
<div class="hint">Installation/Dienststeuerung nur über die Buttons unten.</div>
</div>
</div>
{% if status.installed %}
<div class="flex gap-2" style="align-items:center; margin-bottom:10px; flex-wrap:wrap;">
<span class="pill online">Installiert</span>
{% if status.active is sameas true %}
<span class="pill online">Dienst aktiv</span>
{% elif status.active is sameas false %}
<span class="pill offline">Dienst inaktiv</span>
{% else %}
<span class="pill unknown">Dienst-Status unbekannt</span>
{% endif %}
{% if status.enabled is sameas true %}<span class="pill user">Autostart an</span>{% endif %}
</div>
{% if status.version %}<div class="text-faint mono" style="font-size:12px; margin-bottom:12px;">{{ status.version }}</div>{% endif %}
{% if can_edit %}
<div class="flex gap-2" style="flex-wrap:wrap;">
<form method="post" data-confirm="{{ dhcp_service }} aktivieren und (neu) starten? Übernimmt die zuletzt geschriebene Konfiguration.">
<input type="hidden" name="dhcp_service_action" value="enable_restart">
<button type="submit" class="btn btn-secondary btn-sm">Aktivieren &amp; (neu) starten</button>
</form>
{% if status.active is sameas true %}
<form method="post" data-confirm="{{ dhcp_service }} stoppen und deaktivieren? Startet nach einem Host-Neustart dann nicht automatisch wieder.">
<input type="hidden" name="dhcp_service_action" value="stop">
<button type="submit" class="btn btn-secondary btn-sm" style="color:var(--danger);">Stoppen</button>
</form>
{% endif %}
</div>
{% endif %}
{% else %}
<span class="pill disabled">Nicht installiert</span>
<p class="text-faint" style="font-size:11.5px; margin:10px 0;">
Kein <code>kea-dhcp4</code> auf diesem Host gefunden.
</p>
{% if can_edit %}
<form method="post" data-confirm="{{ dhcp_package }} jetzt installieren (apt-get)? Startet den Dienst noch nicht.">
<input type="hidden" name="install_dhcp_package" value="1">
<button type="submit" class="btn btn-primary btn-sm">{{ dhcp_package }} installieren</button>
</form>
{% endif %}
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Globale DHCP-Einstellungen</h2>
<div class="hint">Gelten für den gesamten Dienst, unabhängig von den Subnetzen unten.</div>
</div>
</div>
{% if can_edit %}
<form method="post">
<input type="hidden" name="save_dhcp_config" value="1">
<div class="field"><label>Domain</label>
<input type="text" name="dhcp_domain" value="{{ cfg.dhcp_domain }}">
</div>
<div class="field"><label>Lease-Zeit Standard (Sek.)</label>
<input type="number" name="dhcp_lease_default" value="{{ cfg.dhcp_lease_default }}">
</div>
<div class="field"><label>Lease-Zeit Maximum (Sek.)</label>
<input type="number" name="dhcp_lease_max" value="{{ cfg.dhcp_lease_max }}">
</div>
<div class="field"><label>Ausgabepfad (Kea-Konfigurationsdatei)</label>
<input type="text" name="dhcp_output_path" value="{{ cfg.dhcp_output_path }}" class="mono">
<div class="field-hint">Standardmäßig die aktive Kea-Konfiguration. Nach dem Schreiben den Dienst per Button neu starten, damit die Änderung wirksam wird.</div>
</div>
<button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Konfiguration speichern
</button>
</form>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für Änderungen fehlt das Recht „DHCP ändern“.</p>
{% endif %}
</div>
<div class="card card-pad" style="grid-column:1 / -1;">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Subnetze</h2>
<div class="hint">
Ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz bedienen — je Subnetz eigene Range,
optional eigenes Gateway/DNS. Subnet/Netzmaske werden live vom Interface übernommen, nicht manuell gepflegt.
</div>
</div>
{% if can_edit %}
<button type="button" class="btn btn-primary" data-open-modal="addSubnetModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Subnetz hinzufügen
</button>
{% endif %}
</div>
{% if subnets %}
<div class="table-wrap">
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Interface</th><th>Range</th><th>Gateway</th><th>DNS</th><th>Erkanntes Netz</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
{% for s in subnets %}
{% set st = subnet_status.get(s.id) %}
<tr {% if not s.enabled %}style="opacity:.55;"{% endif %}>
<td class="mono">{{ s.interface }}</td>
<td class="mono">{{ s.range_start }} {{ s.range_end }}</td>
<td class="mono text-faint">{{ s.gateway or '(automatisch)' }}</td>
<td class="mono text-faint">{{ s.dns or '—' }}</td>
<td>
{% if not s.enabled %}
<span class="pill disabled">Deaktiviert</span>
{% elif st and st.ok %}
<span class="pill online">Erkannt</span>
<span class="mono text-faint" style="font-size:11.5px;">{{ st.network }}</span>
{% else %}
<span class="pill offline">Nicht erkannt</span>
{% if st %}<span class="text-faint" style="font-size:11px;">{{ st.error }}</span>{% endif %}
{% endif %}
</td>
<td>
{% if can_edit %}
<div class="flex gap-2">
<form method="post">
<input type="hidden" name="toggle_dhcp_subnet" value="{{ s.id }}">
<button type="submit" class="icon-btn" title="{{ 'Deaktivieren' if s.enabled else 'Aktivieren' }}">
{% if s.enabled %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M4.9 4.9l14.2 14.2"/></svg>
{% else %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
{% endif %}
</button>
</form>
<button type="button" class="icon-btn" title="Bearbeiten" data-open-modal="editSubnetModal{{ loop.index }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4z"/></svg>
</button>
<form method="post" data-confirm="Subnetz {{ s.range_start }}{{ s.range_end }} löschen?">
<input type="hidden" name="delete_dhcp_subnet" value="{{ s.id }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Noch kein Subnetz konfiguriert — der Dienst kann so nicht gestartet werden.</p>
{% endif %}
</div>
<div class="card card-pad" style="grid-column:1 / -1;">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">DHCP-Options</h2>
<div class="hint">
Standard-Optionen (analog den "Predefined Options" eines Windows-DHCP-Servers) sind bereits vorbefüllt,
eigene/herstellerspezifische Options lassen sich zusätzlich anlegen — beide global oder pro Client überschreibbar.
</div>
</div>
{% if can_edit %}
<button type="button" class="btn btn-primary" data-open-modal="addOptionModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neue Option
</button>
{% endif %}
</div>
{% set visible_option_defs = option_defs|rejectattr("is_standard")|list + option_defs|selectattr("is_standard")|selectattr("id", "in", option_values.keys()|list)|list %}
{% if visible_option_defs %}
<div class="table-wrap">
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Code</th><th>Name</th><th>Typ</th><th>Herkunft</th><th>Beschreibung</th><th>Globaler Wert</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
{% for d in visible_option_defs|sort(attribute="code") %}
<tr>
<td class="mono">{{ d.code }}</td>
<td class="mono">{{ d.name }}</td>
<td>{{ d.type }}</td>
<td>
{% if d.is_standard %}
<span class="pill" style="background:var(--muted-dim); color:var(--text-faint);">Standard</span>
{% else %}
<span class="pill user">Eigen</span>
{% endif %}
</td>
<td class="text-faint" style="font-size:12px;">{{ d.description or '—' }}</td>
<td>
{% if can_edit %}
<form method="post" class="flex gap-2">
<input type="hidden" name="save_dhcp_option_global" value="1">
<input type="hidden" name="option_def_id" value="{{ d.id }}">
<input type="text" name="value" value="{{ option_values.get(d.id, {}).get('', '') }}" style="max-width:220px;" placeholder="(nicht gesetzt)">
<button type="submit" class="btn btn-secondary btn-sm">Speichern</button>
</form>
<div class="field-hint">Leeren Wert speichern, um eine Standard-Option wieder auszublenden.</div>
{% else %}
{{ option_values.get(d.id, {}).get('', '—') }}
{% endif %}
</td>
<td>
{% if can_edit and not d.is_standard %}
<form method="post" data-confirm="Option „{{ d.name }}“ inkl. aller Werte/Overrides löschen?">
<input type="hidden" name="delete_dhcp_option" value="{{ d.id }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
{% elif d.is_standard %}
<span class="text-faint" title="Standard-Optionen können nicht gelöscht werden">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Noch keine Options in Verwendung.</p>
{% endif %}
{% if can_edit %}
{% set unused_standard_defs = option_defs|selectattr("is_standard")|rejectattr("id", "in", option_values.keys()|list)|sort(attribute="code")|list %}
{% if unused_standard_defs %}
<form method="post" class="flex gap-2" style="margin-top:14px; align-items:flex-end; flex-wrap:wrap;">
<input type="hidden" name="save_dhcp_option_global" value="1">
<div class="field" style="margin:0; min-width:260px;">
<label>Standard-Option hinzufügen</label>
<select name="option_def_id">
{% for d in unused_standard_defs %}
<option value="{{ d.id }}">{{ d.name }} (Code {{ d.code }}) — {{ d.description }}</option>
{% endfor %}
</select>
</div>
<div class="field" style="margin:0;">
<label>Wert</label>
<input type="text" name="value" placeholder="Wert eintragen…" required>
</div>
<button type="submit" class="btn btn-secondary">Hinzufügen</button>
</form>
{% endif %}
{% endif %}
</div>
<div class="card card-pad" style="grid-column:1 / -1;">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Aktive Leases</h2>
<div class="hint">
Direkt aus Kea gelesen (nicht aus den Reservierungen) — zeigt auch Clients OHNE eigene Reservierung,
die sich einfach eine freie IP aus dem Pool genommen haben.
</div>
</div>
</div>
{% if leases is none %}
<p class="text-faint" style="font-size:12.5px;">
Keine Lease-Datei gefunden — der Dienst wurde vermutlich noch nie gestartet oder hat noch keine Adresse vergeben.
</p>
{% elif leases %}
<div class="table-wrap">
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>IP-Adresse</th><th>MAC</th><th>Hostname</th><th>Läuft ab</th><th>Status</th></tr></thead>
<tbody>
{% for l in leases %}
<tr>
<td class="mono">{{ l.ip }}</td>
<td class="mono">{{ l.mac }}</td>
<td>{{ l.hostname or '—' }}</td>
<td class="text-faint mono" style="font-size:12px;">
{% if l.expires_at %}
{{ l.expires_at }} UTC
<br><span class="lease-countdown" data-expire-ms="{{ l.expire_epoch_ms }}"></span>
{% else %}
{% endif %}
</td>
<td>
{% if l.reserved %}
<span class="pill online">Reserviert</span>
{% else %}
<span class="pill unknown" title="Client hat sich diese IP selbst aus dem Pool geholt, ohne eigene Reservierung">
⚠ Ohne Reservierung
</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Aktuell keine aktiven Leases vergeben.</p>
{% endif %}
</div>
<div class="card card-pad" style="grid-column:1 / -1;">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Reservierungen aus den Clients</h2>
<div class="hint">
{{ reservations|length }} aktive{{ ' Clients' if reservations|length != 1 else 'r Client' }} mit gültiger MAC + IP in einem der konfigurierten Subnetze.
{% if skipped_count %}{{ skipped_count }} aktive{{ ' Clients' if skipped_count != 1 else 'r Client' }} ohne MAC/IP übersprungen.{% endif %}
{% if out_of_subnet_count %}{{ out_of_subnet_count }} aktive{{ ' Clients außerhalb' if out_of_subnet_count != 1 else 'r Client außerhalb' }} aller konfigurierten Subnetze übersprungen.{% endif %}
</div>
</div>
{% if can_edit %}
<div class="flex gap-2">
<button type="button" class="btn btn-secondary" data-open-modal="addReservationModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Reservierung hinzufügen
</button>
<form method="post" data-confirm="Konfiguration nach „{{ cfg.dhcp_output_path }}“ schreiben? Der Dienst übernimmt die Änderung erst nach einem Neustart.">
<input type="hidden" name="write_dhcp_file" value="1">
<button type="submit" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
Speichern
</button>
</form>
</div>
{% endif %}
</div>
{% if reservations %}
<div class="table-wrap" style="margin-bottom:18px;">
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Hostname</th><th>MAC</th><th>IP-Adresse</th><th>Name</th><th>Quelle</th><th>Subnetz</th><th>Optionen</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
{% for r in reservations %}
<tr>
<td class="mono">{{ r.hostname }}</td>
<td class="mono">{{ r.mac }}</td>
<td class="mono">{{ r.ip }}</td>
<td>{{ r.name }}</td>
<td>
{% if r.source == 'manual' %}
<span class="pill user">Manuell</span>
{% else %}
<span class="pill" style="background:var(--muted-dim); color:var(--text-faint);">Auto (Client)</span>
{% endif %}
</td>
<td class="mono text-faint" style="font-size:12px;">{{ r.subnet_label }}</td>
<td style="font-size:12px;">
{% for d in option_defs %}
{% set override = option_values.get(d.id, {}).get(r.mac) %}
{% set global_val = option_values.get(d.id, {}).get('') %}
{% if override %}
<span class="pill user" title="{{ d.name }} = {{ override }}">{{ d.name }} (eigen)</span>
{% elif global_val %}
<span class="pill" style="background:var(--muted-dim); color:var(--text-faint);" title="{{ d.name }} = {{ global_val }}">{{ d.name }} (global)</span>
{% endif %}
{% endfor %}
</td>
<td>
<div class="flex gap-2">
{% if option_defs and can_edit %}
<button type="button" class="icon-btn" title="DHCP-Optionen für diesen Client" data-open-modal="deviceOptionsModal{{ loop.index }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h13M20 18h0"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/></svg>
</button>
{% endif %}
{% if can_edit and r.source == 'manual' %}
<form method="post" data-confirm="Manuelle Reservierung für {{ r.mac }} löschen?">
<input type="hidden" name="delete_dhcp_reservation" value="{{ r.manual_id }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
{% elif r.source == 'auto' %}
<span class="text-faint" title="Automatische Reservierungen entstehen aus den Client-Stammdaten und können hier nicht gelöscht werden">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
</span>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Modals außerhalb der Tabelle: ein <div> direkt in <tbody> ist
ungültiges HTML und wird vom Browser aus der Tabelle heraus
"foster-parented" — dabei kann die Eltern-Kind-Beziehung zwischen
Formular und Feldern zerrissen werden. -->
{% if option_defs and can_edit %}
{% for r in reservations %}
<div class="modal-overlay" id="deviceOptionsModal{{ loop.index }}">
<div class="modal">
<form method="post">
<input type="hidden" name="save_dhcp_device_options" value="1">
<input type="hidden" name="mac" value="{{ r.mac }}">
<div class="modal-header">
<h3>DHCP-Optionen — {{ r.name }}</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<p class="text-faint" style="font-size:11.5px; margin:0 0 14px;">
Nur Options mit gesetztem Override sind sichtbar — über das Dropdown weitere hinzufügen.
Leerer Wert übernimmt den globalen Wert (bzw. entfernt den Override).
</p>
<select onchange="poeShowOptionField(this)">
<option value="">+ Option hinzufügen…</option>
{% for d in option_defs %}
{% if not option_values.get(d.id, {}).get(r.mac) %}
<option value="opt-field-{{ d.id }}-{{ loop.index }}-{{ r.mac }}">{{ d.name }} (Code {{ d.code }})</option>
{% endif %}
{% endfor %}
</select>
<!-- Neu ausgewählte Felder landen hier direkt unter dem Dropdown
(siehe poeShowOptionField in app.js), statt irgendwo in der
u.U. langen Liste unten sichtbar zu werden. Bereits gesetzte
Overrides stehen von Anfang an hier drin. -->
<div class="poe-added-options" style="margin:10px 0;">
{% for d in option_defs %}
{% set current = option_values.get(d.id, {}).get(r.mac, '') %}
{% if current %}
<div class="field" id="opt-field-{{ d.id }}-{{ loop.index }}-{{ r.mac }}">
<label>{{ d.name }} <span class="text-faint">(Code {{ d.code }})</span></label>
<input type="text" name="opt_{{ d.id }}" value="{{ current }}"
placeholder="{{ option_values.get(d.id, {}).get('', '(kein globaler Wert)') }}">
</div>
{% endif %}
{% endfor %}
</div>
{% for d in option_defs %}
{% set current = option_values.get(d.id, {}).get(r.mac, '') %}
{% if not current %}
<div class="field" id="opt-field-{{ d.id }}-{{ loop.index }}-{{ r.mac }}" style="display:none;">
<label>{{ d.name }} <span class="text-faint">(Code {{ d.code }})</span></label>
<input type="text" name="opt_{{ d.id }}" value=""
placeholder="{{ option_values.get(d.id, {}).get('', '(kein globaler Wert)') }}">
</div>
{% endif %}
{% endfor %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
{% endfor %}
{% endif %}
{% else %}
<p class="text-faint" style="font-size:12.5px; margin-bottom:18px;">Keine aktiven Clients mit MAC + IP vorhanden.</p>
{% endif %}
<div class="permission-group-title">Generierte Konfiguration (Vorschau, Kea-JSON)</div>
<pre class="code-preview">{{ preview }}</pre>
</div>
</div>
{% if can_edit %}
<div class="modal-overlay" id="addOptionModal">
<div class="modal" style="max-width:1000px;">
<form method="post">
<input type="hidden" name="add_dhcp_option" value="1">
<div class="modal-header">
<h3>Neue DHCP-Option</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field"><label>Code</label>
<input type="number" name="code" min="1" max="254" required placeholder="z.B. 225">
</div>
<div class="field"><label>Name</label>
<input type="text" name="name" required placeholder="z.B. terminal-url" pattern="[a-z][a-z0-9-]*">
<div class="field-hint">Nur Kleinbuchstaben, Ziffern und Bindestrich, muss mit einem Buchstaben beginnen.</div>
</div>
<div class="field"><label>Datentyp</label>
<select name="type">
{% for key, label in option_types %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="field"><label>Beschreibung</label>
<input type="text" name="description" placeholder="z.B. Terminal-Boot-URL für Thin Clients">
</div>
<div class="field"><label>Globaler Wert (optional)</label>
<input type="text" name="initial_value" placeholder="z.B. http://bde/webmmi?M=SGM11">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Anlegen</button>
</div>
</form>
</div>
</div>
<div class="modal-overlay" id="addReservationModal">
<div class="modal">
<form method="post">
<input type="hidden" name="add_dhcp_reservation" value="1">
<div class="modal-header">
<h3>Reservierung hinzufügen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<p class="text-faint" style="font-size:11.5px; margin:0 0 14px;">
Für Geräte, die nicht als Client in dieser App gepflegt werden (z.B. ein externes Gerät) — bewusst
eine eigene, manuelle Aktion, damit keine unüberwachten Clients automatisch eine feste IP bekommen.
</p>
<div class="field"><label>MAC-Adresse</label>
<input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF">
</div>
<div class="field"><label>IP-Adresse</label>
<input type="text" name="ip" required placeholder="z.B. 192.168.1.50">
<div class="field-hint">Muss in einem der oben konfigurierten Subnetze liegen, sonst wird sie beim Schreiben übersprungen.</div>
</div>
<div class="field"><label>Name</label>
<input type="text" name="name" required placeholder="z.B. Empfangsdrucker">
<div class="field-hint">Wird zum Hostname der Reservierung. DHCP-Optionen (z.B. Hostname erzwingen) lassen sich danach über das Options-Symbol in der Tabelle setzen.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
</div>
</div>
<div class="modal-overlay" id="addSubnetModal">
<div class="modal">
<form method="post">
<input type="hidden" name="add_dhcp_subnet" value="1">
<div class="modal-header">
<h3>Subnetz hinzufügen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field"><label>Interface</label>
<select name="interface" required>
{% for iface in interfaces %}
<option value="{{ iface }}">{{ iface }}</option>
{% endfor %}
</select>
</div>
<div class="field"><label>Range Start</label>
<input type="text" name="range_start" required placeholder="z.B. 192.168.1.100">
</div>
<div class="field"><label>Range Ende</label>
<input type="text" name="range_end" required placeholder="z.B. 192.168.1.200">
<div class="field-hint">Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.</div>
</div>
<div class="field"><label>Gateway (optional)</label>
<input type="text" name="gateway" placeholder="Automatisch: erkanntes Gateway des Interfaces">
</div>
<div class="field"><label>DNS-Server (optional)</label>
<input type="text" name="dns" placeholder="z.B. 1.1.1.1, 8.8.8.8">
<div class="field-hint">Kommagetrennt, falls mehrere.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
</div>
</div>
{% endif %}
<!-- Bearbeiten-Modals außerhalb der Karte/Tabelle (siehe Kommentar oben
bei den Reservierungen — dieselbe HTML-Validität-Begründung). -->
{% if can_edit %}
{% for s in subnets %}
<div class="modal-overlay" id="editSubnetModal{{ loop.index }}">
<div class="modal">
<form method="post">
<input type="hidden" name="edit_dhcp_subnet" value="{{ s.id }}">
<div class="modal-header">
<h3>Subnetz bearbeiten</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field"><label>Interface</label>
<select name="interface" required>
{% for iface in interfaces %}
<option value="{{ iface }}" {% if iface == s.interface %}selected{% endif %}>{{ iface }}</option>
{% endfor %}
</select>
</div>
<div class="field"><label>Range Start</label>
<input type="text" name="range_start" value="{{ s.range_start }}" required>
</div>
<div class="field"><label>Range Ende</label>
<input type="text" name="range_end" value="{{ s.range_end }}" required>
<div class="field-hint">Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.</div>
</div>
<div class="field"><label>Gateway (optional)</label>
<input type="text" name="gateway" value="{{ s.gateway or '' }}" placeholder="Automatisch: erkanntes Gateway des Interfaces">
</div>
<div class="field"><label>DNS-Server (optional)</label>
<input type="text" name="dns" value="{{ s.dns or '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8">
<div class="field-hint">Kommagetrennt, falls mehrere.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
{% endfor %}
{% endif %}
{% endblock %}
@@ -0,0 +1,114 @@
{% extends "base.html" %}
{% set active_page = "settings_importexport" %}
{% block page_title %}Im-/Export{% endblock %}
{% block page_sub %}<div class="topbar-sub">Umzug auf eine neue Umgebung</div>{% endblock %}
{% block content %}
<div class="settings-grid">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Import</h2>
<div class="hint">
{% if import_preview %}
Datei erfolgreich gelesen — wähle aus, welche Kategorien tatsächlich eingespielt werden sollen.
{% else %}
Exportiertes Bundle einlesen — nach dem Entschlüsseln wählst du aus, was übernommen wird.
{% endif %}
</div>
</div>
</div>
{% if not current_user.has_permission('settings_importexport.edit') %}
<p class="text-faint" style="font-size:12.5px;">Für den Import fehlt das Recht „Im-/Export ändern“.</p>
{% elif import_preview %}
<form method="post" action="{{ url_for('import_apply') }}"
data-confirm="Ausgewählte Kategorien wirklich importieren? Bestehende Einträge mit gleichem Namen/Hostname/MAC/Benutzernamen werden überschrieben.">
<input type="hidden" name="import_token" value="{{ import_preview.token }}">
<div class="field">
<label>Was importieren?</label>
<div class="check-list">
{% for s in import_preview.sections %}
<label class="check-row">
<input type="checkbox" name="import_sections" value="{{ s.key }}"
{% if s.key not in admin_only_sections or current_user.is_admin %}checked{% endif %}
{% if s.admin_only and not current_user.is_admin %}disabled{% endif %}>
{{ s.label }} <span class="text-faint">({{ s.count }})</span>
{% if s.admin_only %}<span class="pill user" style="font-size:10px; padding:2px 7px;">Nur Admin</span>{% endif %}
</label>
{% endfor %}
</div>
<div class="field-hint">
AD/LDAP-Benutzerkonten sind hiervon unberührt — sie werden über Active Directory/Windows verwaltet,
nicht über diese App, und beim nächsten Login automatisch neu angelegt.
</div>
</div>
<button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Ausgewählte Kategorien importieren
</button>
</form>
<a href="{{ url_for('settings_import_export') }}" class="text-faint" style="font-size:12px; display:inline-block; margin-top:10px;">Abbrechen / andere Datei wählen</a>
{% else %}
<form method="post" action="{{ url_for('import_data') }}" enctype="multipart/form-data">
<div class="field">
<label for="import_file">Export-Datei</label>
<input type="file" name="import_file" id="import_file" accept=".json" required>
</div>
<div class="field">
<label for="import_passphrase">Passphrase</label>
<input type="password" name="import_passphrase" id="import_passphrase" required>
</div>
<button type="submit" class="btn btn-secondary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 9V5a2 2 0 00-2-2H5a2 2 0 00-2 2v4"/><path d="M7 14l5-5 5 5"/><path d="M12 9v12"/></svg>
Datei lesen &amp; Vorschau anzeigen
</button>
</form>
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Export</h2>
<div class="hint">Ausgewählte Kategorien verschlüsselt sichern.</div>
</div>
</div>
{% if current_user.has_permission('settings_importexport.export') %}
<form method="post" action="{{ url_for('export_data') }}">
<div class="field">
<label>Was exportieren?</label>
<div class="check-list">
{% for key, label in export_sections %}
<label class="check-row">
<input type="checkbox" name="export_sections" value="{{ key }}"
{% if key not in admin_only_sections or current_user.is_admin %}checked{% endif %}
{% if key in admin_only_sections and not current_user.is_admin %}disabled{% endif %}>
{{ label }}
{% if key in admin_only_sections %}<span class="pill user" style="font-size:10px; padding:2px 7px;">Nur Admin</span>{% endif %}
</label>
{% endfor %}
</div>
<div class="field-hint">
AD/LDAP-Benutzerkonten werden nie mitexportiert — sie werden über Active Directory/Windows verwaltet
(nicht über diese App) und legen sich beim nächsten Login automatisch wieder an. Beim LDAP-Export wird
lediglich das Bind-Konto (verschlüsselt) sowie die Gruppenzuordnungen gesichert.
</div>
</div>
<div class="field">
<label for="export_passphrase">Passphrase</label>
<input type="password" name="export_passphrase" id="export_passphrase" required>
<div class="field-hint">Wird zum Verschlüsseln der Export-Datei benötigt — für den späteren Import dieselbe Passphrase erneut eingeben.</div>
</div>
<button type="submit" class="btn btn-secondary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
Export herunterladen
</button>
</form>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für den Export fehlt das Recht „Daten exportieren“.</p>
{% endif %}
</div>
</div>
{% endblock %}
+229
View File
@@ -0,0 +1,229 @@
{% extends "base.html" %}
{% set active_page = "settings_ldap" %}
{% set can_edit = current_user.has_permission('settings_ldap.edit') %}
{% block page_title %}LDAP / Active Directory{% endblock %}
{% block page_sub %}<div class="topbar-sub">Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten</div>{% endblock %}
{% block content %}
<div class="settings-grid" style="grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Verbindung</h2>
<div class="hint">Server, Bind-Konto und Suchparameter für die Anbindung an AD/LDAP.</div>
</div>
</div>
{% if can_edit %}
<form method="post">
<div class="field">
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
<input type="checkbox" name="ldap_enabled" {% if ldap.enabled %}checked{% endif %}>
<span class="track"></span>
<span>LDAP-Anmeldung aktivieren</span>
</label>
</div>
<div class="field"><label>Server</label>
<input type="text" name="ldap_server" value="{{ ldap.server }}" placeholder="z.B. 192.168.1.1 oder dc01.firma.local">
<div class="field-hint">DNS-Name oder IP-Adresse — beides wird genau so gespeichert und beim Verbinden verwendet.</div>
</div>
<div class="field">
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
<input type="checkbox" name="ldap_use_ssl" id="ldap_use_ssl" {% if ldap.use_ssl %}checked{% endif %}
onchange="document.getElementById('ldap_port').value = this.checked ? 636 : 389;">
<span class="track"></span>
<span>LDAPS/TLS verwenden</span>
</label>
<div class="field-hint">Ohne LDAPS wird das Passwort unverschlüsselt übertragen — nur für interne Tests geeignet, vor Produktivbetrieb LDAPS auf dem Domain Controller einrichten. Stellt beim Umschalten den Port automatisch auf 636/389 — unten weiterhin manuell änderbar.</div>
</div>
<div class="field"><label>Port</label>
<input type="number" name="ldap_port" id="ldap_port" min="1" max="65535" value="{{ ldap.port }}">
</div>
<div class="field">
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
<input type="checkbox" name="ldap_tls_skip_verify" {% if ldap.tls_skip_verify %}checked{% endif %}>
<span class="track"></span>
<span>Zertifikatsprüfung überspringen (nur LDAPS)</span>
</label>
<div class="field-hint">Akzeptiert jedes Server-Zertifikat, auch selbstsignierte/nicht vertrauenswürdige — praktisch für interne Tests, schützt dann aber nicht mehr vor einem gefälschten Server. Vor Produktivbetrieb ein echtes, vertrauenswürdiges Zertifikat einrichten und diese Option deaktivieren.</div>
</div>
<div class="field"><label>Bind-Konto (Service-Account)</label>
<input type="text" name="ldap_bind_dn" value="{{ ldap.bind_dn }}" placeholder="z.B. ldap@ad.firma.local">
<div class="field-hint">Ein normales, unprivilegiertes Domänenkonto reicht — es wird nur zum Suchen von Benutzern verwendet, keine Admin-Rechte nötig. Ein neu gespeichertes Konto ersetzt das bisherige vollständig.</div>
</div>
<div class="field"><label>Bind-Passwort</label>
<input type="password" name="ldap_bind_password" placeholder="{{ '(unverändert lassen)' if ldap.bind_password_enc else '' }}">
</div>
<div class="field"><label>Base-DN</label>
<input type="text" name="ldap_base_dn" value="{{ ldap.base_dn }}" placeholder="Leer = automatisch ermitteln">
</div>
<div class="field"><label>Attribut für Benutzername</label>
<input type="text" name="ldap_user_filter_attr" value="{{ ldap.filter_attr }}" placeholder="sAMAccountName">
<div class="field-hint">Für Active Directory: sAMAccountName. Für generisches LDAP (z.B. OpenLDAP): meist uid. Anmeldung per userPrincipalName (E-Mail/UPN) funktioniert unabhängig davon immer zusätzlich.</div>
</div>
<div class="field"><label>Standardgruppe für neue AD-Benutzer</label>
<select name="ldap_default_group">
<option value="">Systemstandard ({{ ldap_groups|selectattr('is_default')|map(attribute='name')|first or 'Benutzer' }})</option>
{% for g in ldap_groups %}
<option value="{{ g['id'] }}" {% if ldap.default_group == g['id']|string %}selected{% endif %}>{{ g['name'] }}</option>
{% endfor %}
</select>
<div class="field-hint">Wird nur zugewiesen, wenn unten keine AD-Gruppenzuordnung greift — siehe Karte „AD-Gruppenzuordnungen“.</div>
</div>
<div class="flex gap-2" style="flex-wrap:wrap;">
<button type="submit" name="save_ldap" value="1" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Speichern
</button>
<button type="submit" name="test_ldap" value="1" class="btn btn-secondary" formnovalidate>
Verbindung testen
</button>
</div>
</form>
{% if ldap.bind_dn %}
<form method="post" data-confirm="LDAP-Bind-Konto wirklich löschen? Die LDAP-Anmeldung wird dabei deaktiviert." style="margin-top:10px;">
<input type="hidden" name="clear_ldap_bind" value="1">
<button type="submit" class="btn btn-sm" style="color:var(--danger); background:transparent; border-color:var(--danger-dim);">
Bind-Konto löschen
</button>
</form>
{% endif %}
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für Änderungen fehlt das Recht „LDAP/AD-Konfiguration speichern“.</p>
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">AD-Gruppenzuordnungen</h2>
<div class="hint">
Ist ein AD-Benutzer (rekursiv, auch über verschachtelte Gruppen) Mitglied einer hier zugeordneten
AD-Gruppe, erhält er beim Login zusätzlich die zugeordnete App-Rechtegruppe — additiv, mehrere
Zuordnungen können gleichzeitig greifen. Wird keine Zuordnung getroffen, gilt die Standardgruppe oben.
</div>
</div>
{% if can_edit %}
<button type="button" class="btn btn-primary" data-open-modal="addLdapMappingModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Zuordnung hinzufügen
</button>
{% endif %}
</div>
{% if mappings %}
<div class="table-wrap">
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>AD-Gruppe</th><th>App-Rechtegruppe</th><th style="width:1%;">Aktionen</th></tr></thead>
<tbody>
{% for m in mappings %}
<tr>
<td>{{ m.ad_group_name }}<div class="text-faint mono" style="font-size:11px;">{{ m.ad_group_dn }}</div></td>
<td>{{ m.app_group_name or '—' }}</td>
<td>
{% if can_edit %}
<form method="post" data-confirm="Zuordnung „{{ m.ad_group_name }} → {{ m.app_group_name }}“ löschen?">
<input type="hidden" name="delete_ldap_group_mapping" value="{{ m.id }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Noch keine Zuordnung angelegt — alle neuen AD-Benutzer erhalten nur die Standardgruppe.</p>
{% endif %}
</div>
</div>
{% if can_edit %}
<div class="modal-overlay" id="addLdapMappingModal">
<div class="modal">
<form method="post">
<input type="hidden" name="add_ldap_group_mapping" value="1">
<div class="modal-header">
<h3>AD-Gruppenzuordnung hinzufügen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>AD-Gruppe</label>
<div class="flex gap-2">
<select name="ad_group_dn" id="ldapMappingAdGroup" required style="flex:1;">
<option value="">— zuerst laden —</option>
</select>
<button type="button" class="btn btn-secondary btn-sm" id="ldapMappingLoadGroupsBtn">Gruppen laden</button>
</div>
<input type="hidden" name="ad_group_name" id="ldapMappingAdGroupName">
<div class="field-hint" id="ldapMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
</div>
<div class="field"><label>App-Rechtegruppe</label>
<select name="app_group_id" required>
<option value="">— auswählen —</option>
<option value="admin">Admin (alle Rechte)</option>
{% for g in ldap_groups %}
<option value="{{ g['id'] }}">{{ g['name'] }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
<script>
(function () {
var btn = document.getElementById('ldapMappingLoadGroupsBtn');
var select = document.getElementById('ldapMappingAdGroup');
var nameField = document.getElementById('ldapMappingAdGroupName');
var status = document.getElementById('ldapMappingLoadStatus');
if (!btn) return;
btn.addEventListener('click', function () {
status.textContent = 'Lade Gruppen …';
fetch("{{ url_for('settings_ldap_ad_groups') }}")
.then(function (r) { return r.json(); })
.then(function (groups) {
if (!Array.isArray(groups)) {
status.textContent = groups.error || 'Fehler beim Laden.';
return;
}
select.innerHTML = '';
if (!groups.length) {
select.innerHTML = '<option value="">Keine Gruppen gefunden</option>';
status.textContent = 'Keine Gruppen gefunden — Verbindung/Bind-Konto prüfen.';
return;
}
groups.forEach(function (g) {
var opt = document.createElement('option');
opt.value = g.dn;
opt.textContent = g.name;
opt.dataset.name = g.name;
select.appendChild(opt);
});
nameField.value = select.options[select.selectedIndex].dataset.name || '';
status.textContent = groups.length + ' Gruppe(n) geladen.';
})
.catch(function () { status.textContent = 'Fehler beim Laden — Verbindung/Bind-Konto prüfen.'; });
});
select.addEventListener('change', function () {
var opt = select.options[select.selectedIndex];
nameField.value = (opt && opt.dataset.name) || '';
});
})();
</script>
{% endif %}
{% endblock %}
+434
View File
@@ -0,0 +1,434 @@
{% extends "base.html" %}
{% set active_page = "switches" %}
{% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/vendor/xterm.css') }}">
{% endblock %}
{% set can_create = current_user.has_permission('switches.create') %}
{% set can_edit = current_user.has_permission('switches.edit') %}
{% set can_delete = current_user.has_permission('switches.edit') %}
{% block page_title %}Switche{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ switches|length }} Switche</div>{% endblock %}
{% block content %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Switche</h2>
<div class="hint">Aruba-Switche mit ihren Zugangsdaten für PoE-Neustart und Verbindungstest.</div>
</div>
{% if can_create %}
<button type="button" class="btn btn-primary" data-open-modal="addSwitchModal" onclick="resetCredentialChoice('add')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neuer Switch
</button>
{% endif %}
</div>
<div class="table-wrap">
<div class="table-toolbar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input type="text" id="switchSearch" placeholder="Switches durchsuchen…" oninput="filterTable('switchSearch','switchesTable')">
</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table" id="switchesTable" data-sortable>
<thead>
<tr>
<th data-sort-key="hostname">Hostname</th>
<th data-sort-key="ip">IP-Adresse</th>
<th data-sort-key="ssh_port">SSH-Port</th>
<th data-sort-key="credential">Zugangsdaten</th>
<th style="width:1%;">Aktionen</th>
</tr>
</thead>
<tbody>
{% for s in switches %}
<tr data-sort-hostname="{{ s['hostname']|lower }}" data-sort-ip="{{ s['ip']|lower }}" data-sort-ssh_port="{{ s['ssh_port'] or 22 }}" data-sort-credential="{{ (s['credential_name'] or '')|lower }}">
<td class="cell-name">{{ s['hostname'] }}</td>
<td class="mono">{{ s['ip'] }}</td>
<td class="mono">
{{ s['ssh_port'] or 22 }}
{% if not s['ssh_port'] %}<span class="text-faint" style="font-size:11px;">(Standard)</span>{% endif %}
</td>
<td>
{% if s['credential_name'] %}
{{ s['credential_name'] }} <span class="text-faint mono" style="font-size:11.5px;">({{ s['credential_username'] }})</span>
{% else %}
<span class="text-faint">— keine —</span>
{% endif %}
</td>
<td>
<div class="row-actions">
{% if can_edit %}
<button class="icon-btn" title="Bearbeiten" data-open-modal="editSwitchModal{{ loop.index }}" onclick="resetCredentialChoice('edit{{ loop.index }}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
</button>
{% endif %}
{% if can_delete %}
<form method="post" action="{{ url_for('delete_switch', hostname=s['hostname']) }}" data-confirm="Willst du den Switch „{{ s['hostname'] }}“ wirklich löschen?">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="5">Noch keine Switche vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Bearbeiten-Modals außerhalb der Tabelle: ein <div> direkt in <tbody>
ist ungültiges HTML — Browser "foster-parenten" es dann aus der
Tabelle heraus und zerreißen dabei mitunter die Eltern-Kind-Beziehung
zwischen Formular und Buttons (this.closest('form') lieferte dadurch
null, "Verbindung testen" öffnete kein Terminal mehr). -->
{% if can_edit %}
{% for s in switches %}
<div class="modal-overlay" id="editSwitchModal{{ loop.index }}">
<div class="modal" style="max-width:1000px;">
<form method="post" onsubmit="return validateSwitchForm(this, 'edit{{ loop.index }}');">
<input type="hidden" name="edit_switch" value="1">
<input type="hidden" name="old_hostname" value="{{ s['hostname'] }}">
<div class="modal-header">
<h3>Switch bearbeiten</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field"><label>Hostname</label>
<input type="text" name="hostname" value="{{ s['hostname'] }}" required>
</div>
<div class="field"><label>IP-Adresse</label>
<input type="text" name="ip" value="{{ s['ip'] }}" required placeholder="z.B. 192.168.1.100">
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
</div>
<div class="field"><label>SSH-Port</label>
<input type="number" name="ssh_port" value="{{ s['ssh_port'] or '' }}" min="1" max="65535" placeholder="22 (Standard)">
<div class="field-hint">Leer lassen, wenn der Switch den Standard-Port 22 verwendet.</div>
</div>
<div class="field">
<label>Zugangsdaten</label>
<select name="credential_choice" class="credential-select" onchange="toggleNewCredentialFields(this)">
{% for c in all_credentials %}
<option value="{{ c['id'] }}" data-username="{{ c['username'] }}" {% if c['id'] == s['credential_id'] %}selected{% endif %}>{{ c['name'] }}</option>
{% endfor %}
<option value="new">+ Neue Zugangsdaten anlegen</option>
</select>
</div>
<div class="new-credential-fields hidden">
<div class="field"><label>Name der Zugangsdaten</label>
<input type="text" name="new_credential_name" placeholder="z.B. Lager-Switche">
</div>
<div class="field"><label>Username</label>
<input type="text" name="new_credential_username" placeholder="z.B. admin">
</div>
<div class="field"><label>Passwort</label>
<input type="password" id="password_edit{{ loop.index }}" name="new_credential_password">
</div>
<div class="field"><label>Passwort bestätigen</label>
<input type="password" id="password_confirm_edit{{ loop.index }}" name="new_credential_password_confirm">
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" style="margin-right:auto;" onclick="openTerminal(this.closest('form'))">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/></svg>
Verbindung testen
</button>
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
{% endfor %}
{% endif %}
{% if can_create %}
<!-- Modal: Neuer Switch -->
<div class="modal-overlay" id="addSwitchModal">
<div class="modal" style="max-width:1000px;">
<form method="post" onsubmit="return validateSwitchForm(this, 'add');">
<div class="modal-header">
<h3>Neuen Switch hinzufügen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<input type="hidden" name="add_switch" value="1">
<div class="field"><label>Hostname</label>
<input type="text" name="hostname" required placeholder="z.B. Switch01">
</div>
<div class="field"><label>IP-Adresse</label>
<input type="text" name="ip" required placeholder="z.B. 192.168.1.100">
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
</div>
<div class="field"><label>SSH-Port</label>
<input type="number" name="ssh_port" min="1" max="65535" placeholder="22 (Standard)">
<div class="field-hint">Leer lassen, wenn der Switch den Standard-Port 22 verwendet.</div>
</div>
<div class="field">
<label>Zugangsdaten</label>
<select name="credential_choice" class="credential-select" onchange="toggleNewCredentialFields(this)">
{% if not all_credentials %}<option value="new" selected>+ Neue Zugangsdaten anlegen</option>
{% else %}
{% for c in all_credentials %}
<option value="{{ c['id'] }}" data-username="{{ c['username'] }}">{{ c['name'] }}</option>
{% endfor %}
<option value="new">+ Neue Zugangsdaten anlegen</option>
{% endif %}
</select>
</div>
<div class="new-credential-fields {% if all_credentials %}hidden{% endif %}">
<div class="field"><label>Name der Zugangsdaten</label>
<input type="text" name="new_credential_name" placeholder="z.B. Lager-Switche">
</div>
<div class="field"><label>Username</label>
<input type="text" name="new_credential_username" placeholder="z.B. admin">
</div>
<div class="field"><label>Passwort</label>
<input type="password" id="password_add" name="new_credential_password">
</div>
<div class="field"><label>Passwort bestätigen</label>
<input type="password" id="password_confirm_add" name="new_credential_password_confirm">
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" style="margin-right:auto;" onclick="openTerminal(this.closest('form'))">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/></svg>
Verbindung testen
</button>
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
</div>
</div>
{% endif %}
{% if can_create or can_edit %}
<!-- Modal: SSH-Verbindungstest (echtes Terminal, zum Akzeptieren von Host-Keys
und Prüfen der Zugangsdaten, bevor der Switch gespeichert wird) -->
<div class="modal-overlay" id="terminalModal">
<div class="modal" style="max-width:720px;">
<div class="modal-header">
<h3>SSH-Verbindungstest</h3>
<button type="button" class="modal-close" data-close-modal onclick="closeTerminal()">&times;</button>
</div>
<div class="modal-body" style="padding:0;">
<div class="term-toolbar">
<div class="flex gap-2" style="align-items:center;">
<span id="termStatus" class="pill unknown">Bereit</span>
<span id="termTarget" class="text-faint mono" style="font-size:12px;"></span>
</div>
<button type="button" class="btn btn-sm btn-secondary" id="termPastePassword">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
Passwort einfügen
</button>
</div>
<div id="terminal" class="xterm-container"></div>
</div>
<div class="modal-footer">
<p class="text-faint" style="font-size:11.5px; margin-right:auto;">
Erste Verbindung? Bestätige den Host-Key mit „yes“, gib danach das Passwort ein — direkt hier im Terminal.
Bei bestehenden Zugangsdaten ist das Passwort hier nicht bekannt (verschlüsselt gespeichert) — bitte manuell eingeben.
</p>
<button type="button" class="btn btn-secondary" data-close-modal onclick="closeTerminal()">Schließen</button>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/vendor/xterm.js') }}"></script>
<script src="{{ url_for('static', filename='js/vendor/xterm-addon-fit.js') }}"></script>
<script>
// -------------------------------------------------------------------------
// Zugangsdaten-Auswahl: "+ Neue Zugangsdaten anlegen" blendet die Felder ein
// -------------------------------------------------------------------------
function toggleNewCredentialFields(select) {
const fields = select.closest(".modal-body").querySelector(".new-credential-fields");
if (fields) fields.classList.toggle("hidden", select.value !== "new");
}
function resetCredentialChoice(id) {
// Beim Öffnen sicherstellen, dass die "Neue Zugangsdaten"-Felder passend
// zur aktuellen Auswahl ein-/ausgeblendet sind (relevant v.a. nach
// vorherigem Umschalten auf "neu" ohne zu speichern).
setTimeout(() => {
const select = document.querySelector(`#${id === 'add' ? 'addSwitchModal' : 'editSwitchModal' + id.replace('edit','')} .credential-select`);
if (select) toggleNewCredentialFields(select);
}, 0);
}
// -------------------------------------------------------------------------
// SSH-Verbindungstest (Web-Terminal via /ws/ssh_terminal)
// -------------------------------------------------------------------------
let term = null, fitAddon = null, termSocket = null, activePasswordInput = null;
function ensureTerminal() {
if (term) return;
term = new Terminal({
convertEol: true,
fontSize: 13,
fontFamily: "ui-monospace, SFMono-Regular, Consolas, monospace",
cursorBlink: true,
theme: { background: "#0a0c10", foreground: "#c7ccd6" },
});
fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById("terminal"));
fitAddon.fit();
term.onData((data) => {
if (termSocket && termSocket.readyState === WebSocket.OPEN) {
termSocket.send(JSON.stringify({ type: "input", data }));
}
});
term.onResize(({ cols, rows }) => {
if (termSocket && termSocket.readyState === WebSocket.OPEN) {
termSocket.send(JSON.stringify({ type: "resize", cols, rows }));
}
});
window.addEventListener("resize", () => fitAddon && fitAddon.fit());
}
function setTermStatus(text, cls) {
const el = document.getElementById("termStatus");
el.className = "pill " + cls;
el.innerText = text;
}
function getCredentialInfo(form) {
const select = form.querySelector(".credential-select");
if (!select || select.value === "new") {
return {
username: (form.querySelector("input[name='new_credential_username']") || {}).value?.trim(),
passwordInput: form.querySelector("input[name='new_credential_password']"),
};
}
const option = select.selectedOptions[0];
return { username: option ? option.dataset.username : null, passwordInput: null };
}
function openTerminal(form) {
const host = (form.querySelector("input[name='ip']") || {}).value?.trim();
const port = parseInt((form.querySelector("input[name='ssh_port']") || {}).value, 10) || 22;
const { username, passwordInput } = getCredentialInfo(form);
activePasswordInput = passwordInput;
if (!host || !username) {
showToast("Bitte IP-Adresse ausfüllen und Zugangsdaten auswählen/anlegen, bevor du die Verbindung testest.", "danger");
return;
}
PoeUI.openModal("terminalModal");
ensureTerminal();
term.reset();
document.getElementById("termTarget").innerText = `${username}@${host}:${port}`;
setTermStatus("Verbinde…", "unknown");
setTimeout(() => fitAddon && fitAddon.fit(), 60);
if (termSocket) { try { termSocket.close(); } catch (e) {} }
const proto = location.protocol === "https:" ? "wss:" : "ws:";
termSocket = new WebSocket(`${proto}//${location.host}/ws/ssh_terminal`);
termSocket.onopen = () => {
termSocket.send(JSON.stringify({ host, username, port }));
setTermStatus("Verbunden", "online");
};
termSocket.onmessage = (event) => term.write(event.data);
termSocket.onclose = () => setTermStatus("Getrennt", "offline");
// Bewusst kein Toast bei "error": manche Browser melden beim Schließen
// einer WebSocket-Verbindung ein error-Event, obwohl die Sitzung
// inhaltlich erfolgreich war. Terminal-Inhalt + Status-Pill genügen als
// Feedback — der Verbindungsstatus wird zuverlässig über onclose gepflegt.
termSocket.onerror = () => setTermStatus("Fehler", "offline");
}
function closeTerminal() {
if (termSocket) {
try { termSocket.close(); } catch (e) {}
termSocket = null;
}
}
const termPasteBtn = document.getElementById("termPastePassword");
if (termPasteBtn) {
termPasteBtn.addEventListener("click", () => {
if (!activePasswordInput || !activePasswordInput.value) {
showToast("Kein Passwort bekannt — bitte manuell im Terminal eingeben.", "danger");
return;
}
if (!termSocket || termSocket.readyState !== WebSocket.OPEN) {
showToast("Keine aktive Terminal-Verbindung.", "danger");
return;
}
termSocket.send(JSON.stringify({ type: "input", data: activePasswordInput.value + "\n" }));
});
}
// Verbindung auch beim Schließen per Klick auf Backdrop / Escape sauber trennen
const terminalModalEl = document.getElementById("terminalModal");
if (terminalModalEl) {
terminalModalEl.addEventListener("click", (e) => {
if (e.target.id === "terminalModal") closeTerminal();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && terminalModalEl.classList.contains("open")) closeTerminal();
});
}
const ipPattern = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
function validateIP(input) {
const ok = ipPattern.test(input.value);
input.classList.toggle("is-invalid", !ok);
return ok;
}
function validateNewCredentialPassword(form) {
const select = form.querySelector(".credential-select");
if (!select || select.value !== "new") return true;
const pass = form.querySelector("input[name='new_credential_password']");
const confirm = form.querySelector("input[name='new_credential_password_confirm']");
if (!pass || !confirm) return true;
if (pass.value !== confirm.value) { confirm.classList.add("is-invalid"); return false; }
confirm.classList.remove("is-invalid");
return true;
}
function validateSwitchForm(form, id) {
const ipInput = form.querySelector("input[name='ip']");
let valid = true;
if (ipInput) valid = validateIP(ipInput) && valid;
valid = validateNewCredentialPassword(form) && valid;
return valid;
}
document.addEventListener("input", (e) => {
if (e.target.name === "ip") validateIP(e.target);
if (e.target.name === "new_credential_password_confirm") {
const form = e.target.closest("form");
const pass = form.querySelector("input[name='new_credential_password']");
if (pass) e.target.classList.toggle("is-invalid", pass.value !== e.target.value);
}
});
function filterTable(inputId, tableId) {
const q = document.getElementById(inputId).value.trim().toLowerCase();
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
if (row.classList.contains("empty-row")) return;
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
});
}
</script>
{% endblock %}
+321
View File
@@ -0,0 +1,321 @@
{% extends "base.html" %}
{% set active_page = "users" %}
{% block page_title %}Benutzer{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ users|length }} Benutzer</div>{% endblock %}
{% block content %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Benutzer</h2>
<div class="hint">Die Gruppe bestimmt die Rechte eines Benutzers.</div>
</div>
{% if current_user.has_permission('users.create') %}
<div class="flex gap-2">
{% if ldap_enabled %}
<button type="button" class="btn btn-secondary" data-open-modal="ldapAddModal" onclick="resetLdapSearch();">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
Aus Active Directory hinzufügen
</button>
{% endif %}
<button type="button" class="btn btn-primary" data-open-modal="userModal" onclick="document.getElementById('userForm').reset();">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neuer Benutzer
</button>
</div>
{% endif %}
</div>
<div class="table-wrap">
<div class="table-toolbar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input type="text" id="userSearch" placeholder="Benutzer durchsuchen…" oninput="filterTable('userSearch','usersTable')">
</div>
</div>
<div style="overflow-x:auto;">
<table class="data-table" id="usersTable" data-sortable>
<thead><tr>
<th data-sort-key="username">Username</th>
<th data-sort-key="firstname">Vorname</th>
<th data-sort-key="lastname">Nachname</th>
<th data-sort-key="group">Gruppe</th>
<th style="width:1%;">Aktionen</th>
</tr></thead>
<tbody>
{% for u in users %}
{% set group_label = 'Admin' if u['is_admin'] else (u['group_names'] or '') %}
{% set is_ldap = u['auth_source'] == 'ldap' %}
<tr data-sort-username="{{ u['username']|lower }}" data-sort-firstname="{{ (u['first_name'] or '')|lower }}" data-sort-lastname="{{ (u['last_name'] or '')|lower }}" data-sort-group="{{ group_label|lower }}">
<td class="cell-name">
{{ u['username'] }}
{% if is_ldap %}<span class="pill user" style="font-size:10px; padding:2px 7px;" title="Konto stammt aus Active Directory/LDAP, Passwort wird dort verwaltet">AD</span>{% endif %}
{% if u['is_locked'] %}<span class="pill" style="font-size:10px; padding:2px 7px; background:var(--danger-dim); color:var(--danger);" title="Login für dieses Konto ist gesperrt">Gesperrt</span>{% endif %}
{% if u['email'] %}<div class="text-faint" style="font-size:11px;">{{ u['email'] }}</div>{% endif %}
</td>
<td class="text-dim">{{ u['first_name'] or '—' }}</td>
<td class="text-dim">{{ u['last_name'] or '—' }}</td>
<td>
{% if u['is_admin'] %}
<span class="pill admin">Admin</span>
{% else %}
<span class="text-dim">{{ u['group_names'] or '—' }}</span>
{% endif %}
</td>
{% set may_touch_target = current_user.is_admin or not u['is_admin'] %}
<td>
<div class="row-actions">
{% if current_user.has_permission('users.edit') and may_touch_target and not is_ldap %}
<button class="icon-btn" title="Bearbeiten"
onclick="openEditModal({{ u['id'] }}, '{{ u['username'] }}', '{{ u['first_name'] or '' }}', '{{ u['last_name'] or '' }}', '{{ u['email'] or '' }}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
</button>
{% endif %}
{% if current_user.has_permission('users.edit') and may_touch_target %}
<button class="icon-btn" title="Gruppe zuweisen"
onclick="openGroupModal({{ u['id'] }}, '{{ 'admin' if u['is_admin'] else (u['group_id'] or '') }}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="19" r="2.5"/><path d="M8.2 10.7l7.6-4.4M8.2 13.3l7.6 4.4"/></svg>
</button>
{% endif %}
{% if current_user.has_permission('users.edit') and may_touch_target and u['id'] != current_user.id %}
<form method="post" data-confirm="„{{ u['username'] }}“ wirklich {{ 'entsperren' if u['is_locked'] else 'sperren' }}?">
<input type="hidden" name="toggle_lock" value="{{ u['id'] }}">
<button type="submit" class="icon-btn" title="{{ 'Entsperren' if u['is_locked'] else 'Sperren' }}">
{% if u['is_locked'] %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 019.9-1"/></svg>
{% else %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
{% endif %}
</button>
</form>
{% endif %}
{% if current_user.has_permission('users.edit') and may_touch_target %}
<form method="post" data-confirm="Willst du „{{ u['username'] }}“ wirklich löschen?">
<input type="hidden" name="delete_user" value="{{ u['id'] }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="5">Noch keine Benutzer vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Modal: Neuer Benutzer -->
<div class="modal-overlay" id="userModal">
<div class="modal" style="max-width:1000px;">
<form method="post" id="userForm">
<div class="modal-header">
<h3>Neuen Benutzer anlegen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field"><label>Vorname</label><input type="text" name="first_name"></div>
<div class="field"><label>Name</label><input type="text" name="last_name"></div>
<div class="field"><label>Username</label><input type="text" name="username" required></div>
<div class="field"><label>E-Mail</label><input type="email" name="email" placeholder="optional — ermöglicht Login per E-Mail"></div>
<div class="field"><label>Passwort</label><input type="password" name="password" required></div>
<div class="field">
<label>Gruppe</label>
<select name="group_id">
{% for g in all_groups %}<option value="{{ g['id'] }}" {% if g['is_default'] %}selected{% endif %}>{{ g['name'] }}</option>{% endfor %}
{% if current_user.is_admin %}<option value="admin">Admin</option>{% endif %}
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" name="add_user" value="1" class="btn btn-primary">Anlegen</button>
</div>
</form>
</div>
</div>
<!-- Modal: Benutzer bearbeiten (nur lokale Konten -- AD/LDAP-Konten lassen
sich nur sperren/entsperren und einer Gruppe zuweisen, siehe Tabelle) -->
<div class="modal-overlay" id="editModal">
<div class="modal">
<form method="post" id="editForm">
<input type="hidden" name="user_id" id="edit_user_id">
<div class="modal-header">
<h3>Benutzer bearbeiten</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field"><label>Vorname</label><input type="text" name="first_name" id="edit_first_name"></div>
<div class="field"><label>Name</label><input type="text" name="last_name" id="edit_last_name"></div>
<div class="field"><label>Username</label><input type="text" name="username" id="edit_username" required></div>
<div class="field"><label>E-Mail</label><input type="email" name="email" id="edit_email" placeholder="optional — ermöglicht Login per E-Mail"></div>
<div class="field"><label>Neues Passwort</label>
<input type="password" name="new_password" placeholder="Nur bei Änderung ausfüllen">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" name="edit_user" value="1" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
<!-- Modal: Aus Active Directory hinzufügen (Vorab-Zuweisung vor dem ersten
Login des AD-Benutzers) -->
{% if ldap_enabled %}
<div class="modal-overlay" id="ldapAddModal">
<div class="modal">
<form method="post" id="ldapAddForm">
<input type="hidden" name="ldap_username" id="ldap_add_username">
<div class="modal-header">
<h3>Aus Active Directory hinzufügen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>Suche</label>
<input type="text" id="ldapSearchInput" placeholder="Name, Benutzername oder UPN eingeben …" autocomplete="off">
<div class="field-hint" id="ldapSearchStatus">Mindestens 2 Zeichen eingeben.</div>
</div>
<div id="ldapSearchResults" style="max-height:240px; overflow-y:auto; display:flex; flex-direction:column; gap:4px;"></div>
<div class="field" id="ldapAddGroupField" style="display:none;">
<label>Gruppe für <span id="ldapAddSelectedName"></span></label>
<select name="group_id" id="ldapAddGroupSelect">
{% for g in all_groups %}<option value="{{ g['id'] }}" {% if g['is_default'] %}selected{% endif %}>{{ g['name'] }}</option>{% endfor %}
{% if current_user.is_admin %}<option value="admin">Admin</option>{% endif %}
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" name="ldap_add_user" value="1" class="btn btn-primary" id="ldapAddSubmit" disabled>Hinzufügen</button>
</div>
</form>
</div>
</div>
{% endif %}
<!-- Modal: Gruppe zuweisen (inkl. Admin als Auswahl) -->
<div class="modal-overlay" id="groupModal">
<div class="modal" style="max-width:380px;">
<form method="post" id="groupForm">
<input type="hidden" name="user_id" id="group_user_id">
<div class="modal-header">
<h3>Gruppe zuweisen</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label>Gruppe</label>
<select name="group_id" id="group_select">
<option value="">Keine Gruppe</option>
{% for g in all_groups %}<option value="{{ g['id'] }}">{{ g['name'] }}</option>{% endfor %}
{% if current_user.is_admin %}<option value="admin">Admin</option>{% endif %}
</select>
<div class="field-hint">Ersetzt die bisherige Gruppen-/Rollenzuordnung dieses Benutzers.</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" name="assign_group" value="1" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function filterTable(inputId, tableId) {
const q = document.getElementById(inputId).value.trim().toLowerCase();
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
if (row.classList.contains("empty-row")) return;
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
});
}
function openEditModal(userId, username, firstName, lastName, email) {
document.getElementById("edit_user_id").value = userId;
document.getElementById("edit_username").value = username;
document.getElementById("edit_first_name").value = firstName;
document.getElementById("edit_last_name").value = lastName;
document.getElementById("edit_email").value = email || "";
document.querySelector("#editForm input[name='new_password']").value = "";
PoeUI.openModal("editModal");
}
function openGroupModal(userId, groupChoice) {
document.getElementById("group_user_id").value = userId;
document.getElementById("group_select").value = groupChoice || "";
PoeUI.openModal("groupModal");
}
{% if ldap_enabled %}
function resetLdapSearch() {
document.getElementById("ldapSearchInput").value = "";
document.getElementById("ldapSearchResults").innerHTML = "";
document.getElementById("ldapSearchStatus").textContent = "Mindestens 2 Zeichen eingeben.";
document.getElementById("ldapAddGroupField").style.display = "none";
document.getElementById("ldap_add_username").value = "";
document.getElementById("ldapAddSubmit").disabled = true;
}
(function () {
var input = document.getElementById("ldapSearchInput");
var results = document.getElementById("ldapSearchResults");
var status = document.getElementById("ldapSearchStatus");
if (!input) return;
var debounceTimer = null;
input.addEventListener("input", function () {
var q = input.value.trim();
clearTimeout(debounceTimer);
if (q.length < 2) {
results.innerHTML = "";
status.textContent = "Mindestens 2 Zeichen eingeben.";
return;
}
status.textContent = "Suche …";
debounceTimer = setTimeout(function () {
fetch("{{ url_for('users_ldap_search') }}?q=" + encodeURIComponent(q))
.then(function (r) { return r.json(); })
.then(function (data) {
if (!Array.isArray(data)) {
status.textContent = data.error || "Fehler bei der Suche.";
return;
}
results.innerHTML = "";
if (!data.length) {
status.textContent = "Keine Treffer (oder bereits lokal bekannt).";
return;
}
status.textContent = data.length + " Treffer:";
data.forEach(function (u) {
var full = [u.first_name, u.last_name].filter(Boolean).join(" ");
var row = document.createElement("button");
row.type = "button";
row.className = "btn btn-secondary btn-sm";
row.style.textAlign = "left";
row.style.justifyContent = "flex-start";
row.textContent = u.username + (full ? " — " + full : "") + (u.email ? " (" + u.email + ")" : "");
row.addEventListener("click", function () {
document.getElementById("ldap_add_username").value = u.username;
document.getElementById("ldapAddSelectedName").textContent = u.username;
document.getElementById("ldapAddGroupField").style.display = "";
document.getElementById("ldapAddSubmit").disabled = false;
});
results.appendChild(row);
});
})
.catch(function () { status.textContent = "Fehler bei der Suche."; });
}, 300);
});
})();
{% endif %}
</script>
{% endblock %}