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.
@@ -1,58 +0,0 @@
|
||||
{% 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;">Export</h2>
|
||||
<div class="hint">Geräte, Switche und Zugangsdaten verschlüsselt sichern.</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('export_data') }}">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="card card-pad">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Import</h2>
|
||||
<div class="hint">Exportiertes Bundle einlesen — vorhandene Einträge werden aktualisiert, neue angelegt.</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if current_user.has_permission('settings_importexport.edit') %}
|
||||
<form method="post" action="{{ url_for('import_data') }}" enctype="multipart/form-data"
|
||||
data-confirm="Import wirklich starten? Bestehende Einträge mit gleichem Namen/Hostname/MAC werden überschrieben.">
|
||||
<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>
|
||||
Import starten
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12.5px;">Für den Import fehlt das Recht „Im-/Export ändern“.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,177 +0,0 @@
|
||||
{% 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') %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table" data-sortable>
|
||||
<thead><tr>
|
||||
<th data-sort-key="username">Username</th>
|
||||
<th data-sort-key="name">Name</th>
|
||||
<th data-sort-key="group">Gruppe</th>
|
||||
<th style="width:1%;">Aktionen</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
{% set full_name = [u['first_name'], u['last_name']]|select|join(' ') %}
|
||||
{% set group_label = 'Admin' if u['is_admin'] else (u['group_names'] or '') %}
|
||||
<tr data-sort-username="{{ u['username']|lower }}" data-sort-name="{{ full_name|lower }}" data-sort-group="{{ group_label|lower }}">
|
||||
<td class="cell-name">{{ u['username'] }}</td>
|
||||
<td class="text-dim">{{ full_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 %}
|
||||
<button class="icon-btn" title="Bearbeiten"
|
||||
onclick="openEditModal({{ u['id'] }}, '{{ u['username'] }}', '{{ u['first_name'] or '' }}', '{{ u['last_name'] 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="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 %}
|
||||
<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="4">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>×</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>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 (Stammdaten + optional neues Passwort;
|
||||
Gruppe/Rolle wird ausschließlich über "Gruppe zuweisen" geändert) -->
|
||||
<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>×</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>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: 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>×</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 openEditModal(userId, username, firstName, lastName) {
|
||||
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.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");
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Initialisiert die SQLite-Datenbank für den PoE Manager."""
|
||||
"""Initialisiert die SQLite-Datenbank für TESM."""
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
@@ -38,7 +38,7 @@ CREATE TABLE IF NOT EXISTS switches (
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
mac TEXT PRIMARY KEY,
|
||||
rpi_ip TEXT NOT NULL,
|
||||
ip TEXT NOT NULL,
|
||||
switch_hostname TEXT,
|
||||
port TEXT,
|
||||
name TEXT NOT NULL,
|
||||
@@ -58,7 +58,10 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
avatar_filename TEXT
|
||||
avatar_filename TEXT,
|
||||
auth_source TEXT NOT NULL DEFAULT 'local',
|
||||
email TEXT,
|
||||
is_locked INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""")
|
||||
|
||||
@@ -23,9 +23,9 @@ def generate_ips_list():
|
||||
}
|
||||
|
||||
devices = conn.execute("""
|
||||
SELECT mac, rpi_ip, port, name, switch_hostname
|
||||
SELECT mac, ip, port, name, switch_hostname
|
||||
FROM devices
|
||||
WHERE is_active=1
|
||||
WHERE is_active=1 AND deleted_at IS NULL
|
||||
""").fetchall()
|
||||
conn.close()
|
||||
|
||||
@@ -44,7 +44,7 @@ def generate_ips_list():
|
||||
|
||||
port = dev["port"] or ""
|
||||
print(
|
||||
f"{dev['rpi_ip']}|"
|
||||
f"{dev['ip']}|"
|
||||
f"{dev['name']}|"
|
||||
f"{switch_ip}|"
|
||||
f"{switch_ssh_port}|"
|
||||
@@ -9,8 +9,10 @@ Flask-Login==0.6.3
|
||||
Flask-Sock==0.7.0
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.6
|
||||
ldap3==2.9.1
|
||||
MarkupSafe==3.0.2
|
||||
paramiko==3.5.1
|
||||
pyasn1==0.6.4
|
||||
pycparser==2.23
|
||||
PyNaCl==1.5.0
|
||||
PyYAML==6.0.2
|
||||
@@ -25,3 +27,7 @@ Werkzeug==3.1.3
|
||||
# PyYAML wird für das netplan-Backend der Host-Netzwerkeinstellungen
|
||||
# gebraucht (Systemeinstellungen → Netzwerkeinstellungen) — schreibt/liest
|
||||
# die eigene Override-Datei unter /etc/netplan/.
|
||||
#
|
||||
# ldap3 (reines Python, keine System-Bibliothek wie libldap nötig) für die
|
||||
# optionale Active-Directory/LDAP-Anmeldung (Systemeinstellungen → LDAP) —
|
||||
# Search+Bind gegen einen AD-Domain-Controller oder generischen LDAP-Server.
|
||||
@@ -1002,6 +1002,7 @@ select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5vw;
|
||||
background:
|
||||
radial-gradient(circle at 15% 15%, rgba(255, 113, 0, 0.14), transparent 45%),
|
||||
radial-gradient(circle at 85% 85%, rgba(255, 113, 0, 0.08), transparent 40%),
|
||||
@@ -1009,6 +1010,16 @@ select {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* TESM-Wortmarke (mit Funktionszeile) als eigenes Panel neben der Login-Karte
|
||||
-- nebeneinander statt überlagert, damit beides gleichzeitig sichtbar
|
||||
bleibt. Auf schmalen Bildschirmen ausgeblendet (Platz reicht sonst nicht). */
|
||||
.login-branding { display: none; pointer-events: none; }
|
||||
.login-branding img { width: min(34vw, 520px); height: auto; }
|
||||
|
||||
@media (min-width: 860px) {
|
||||
.login-branding { display: block; }
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="1" width="62" height="62" rx="14" fill="#10151B"></rect>
|
||||
<g transform="translate(5,20.19) scale(0.16875)">
|
||||
<circle cx="298" cy="70" r="15" fill="#E2A63C" opacity="0.22"></circle>
|
||||
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
|
||||
fill="none" stroke="#E2A63C" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.85"></polyline>
|
||||
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
|
||||
font-weight="800" font-size="82" letter-spacing="2" fill="#E7ECEE">TESM</text>
|
||||
<circle cx="298" cy="70" r="8" fill="#E2A63C"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 842 B |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="1" width="62" height="62" rx="14" fill="#EDF1F2" stroke="#C1CBCE" stroke-width="2"></rect>
|
||||
<g transform="translate(5,20.19) scale(0.16875)">
|
||||
<circle cx="298" cy="70" r="15" fill="#B5750E" opacity="0.18"></circle>
|
||||
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
|
||||
fill="none" stroke="#B5750E" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"></polyline>
|
||||
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
|
||||
font-weight="800" font-size="82" letter-spacing="2" fill="#172026">TESM</text>
|
||||
<circle cx="298" cy="70" r="8" fill="#B5750E"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 875 B |
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 620 190" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(150,15)">
|
||||
<circle cx="298" cy="70" r="15" fill="#E2A63C" opacity="0.22"></circle>
|
||||
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
|
||||
fill="none" stroke="#E2A63C" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.85"></polyline>
|
||||
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
|
||||
font-weight="800" font-size="82" letter-spacing="2" fill="#E7ECEE">TESM</text>
|
||||
<circle cx="298" cy="70" r="8" fill="#E2A63C"></circle>
|
||||
</g>
|
||||
<text x="310" y="178" text-anchor="middle" font-family="ui-sans-serif, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||
font-weight="600" font-size="13" letter-spacing="1.5" fill="#E2A63C">ÜBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE)</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 320 140" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="298" cy="70" r="15" fill="#E2A63C" opacity="0.22"></circle>
|
||||
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
|
||||
fill="none" stroke="#E2A63C" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.85"></polyline>
|
||||
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
|
||||
font-weight="800" font-size="82" letter-spacing="2" fill="#E7ECEE">TESM</text>
|
||||
<circle cx="298" cy="70" r="8" fill="#E2A63C"></circle>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 699 B |
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 620 190" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(150,15)">
|
||||
<circle cx="298" cy="70" r="15" fill="#B5750E" opacity="0.18"></circle>
|
||||
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
|
||||
fill="none" stroke="#B5750E" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"></polyline>
|
||||
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
|
||||
font-weight="800" font-size="82" letter-spacing="2" fill="#172026">TESM</text>
|
||||
<circle cx="298" cy="70" r="8" fill="#B5750E"></circle>
|
||||
</g>
|
||||
<text x="310" y="178" text-anchor="middle" font-family="ui-sans-serif, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||
font-weight="600" font-size="13" letter-spacing="1.5" fill="#B5750E">ÜBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE)</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg viewBox="0 0 320 140" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="298" cy="70" r="15" fill="#B5750E" opacity="0.18"></circle>
|
||||
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
|
||||
fill="none" stroke="#B5750E" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"></polyline>
|
||||
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
|
||||
font-weight="800" font-size="82" letter-spacing="2" fill="#172026">TESM</text>
|
||||
<circle cx="298" cy="70" r="8" fill="#B5750E"></circle>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 698 B |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
@@ -14,6 +14,17 @@
|
||||
document.querySelectorAll("[data-theme-icon]").forEach((el) => {
|
||||
el.innerHTML = theme === "light" ? ICONS.moon : ICONS.sun;
|
||||
});
|
||||
// Favicon + Sidebar-Logo folgen demselben Theme wie der Rest der App
|
||||
// (auch bei manuellem Umschalten, nicht nur bei System-Präferenz) --
|
||||
// "dunkel"/"hell" bezeichnen hier die für den jeweiligen Modus gedachte
|
||||
// Logo-Variante (helle Farben für Dark Mode, dunkle für Light Mode).
|
||||
const suffix = theme === "light" ? "light" : "dark";
|
||||
const favicon = document.getElementById("app-favicon");
|
||||
if (favicon) favicon.href = "/static/images/icon-" + suffix + ".svg";
|
||||
const sidebarLogo = document.getElementById("sidebar-logo");
|
||||
if (sidebarLogo) sidebarLogo.src = "/static/images/logo-" + suffix + ".svg";
|
||||
const loginBgLogo = document.getElementById("login-bg-logo");
|
||||
if (loginBgLogo) loginBgLogo.src = "/static/images/logo-" + suffix + "-subline.svg";
|
||||
}
|
||||
|
||||
function initTheme() {
|
||||
@@ -31,7 +31,7 @@
|
||||
data-status="{{ 'disabled' if d['is_active'] == 0 else st }}"
|
||||
data-mac="{{ d['mac'] }}"
|
||||
data-name="{{ d['name'] }}"
|
||||
data-ip="{{ d['rpi_ip'] }}"
|
||||
{% 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'] }}"
|
||||
@@ -48,7 +48,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="dc-name">{{ d['name'] }}</div>
|
||||
<div class="dc-ip">{{ d['rpi_ip'] }}</div>
|
||||
{% if current_user.is_authenticated %}<div class="dc-ip">{{ d['ip'] }}</div>{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -39,80 +39,26 @@
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Passwort ändern</h2>
|
||||
<div class="hint">Erfordert Eingabe des aktuellen Passworts.</div>
|
||||
<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>
|
||||
|
||||
{% 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" style="margin-top:24px;">
|
||||
<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.</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 %}
|
||||
|
||||
{% 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 %}
|
||||
@@ -3,8 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ title or "PoE Manager" }}</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='images/logo.png') }}">
|
||||
<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>
|
||||
@@ -25,6 +25,7 @@
|
||||
"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">
|
||||
@@ -34,10 +35,7 @@
|
||||
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<div>
|
||||
<span class="brand-name">PoE Manager</span>
|
||||
<span class="brand-sub">Device Monitoring</span>
|
||||
</div>
|
||||
<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">
|
||||
@@ -91,6 +89,9 @@
|
||||
<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 %}
|
||||
@@ -23,8 +23,14 @@
|
||||
</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" data-sortable>
|
||||
<table class="data-table" id="credentialsTable" data-sortable>
|
||||
<thead><tr>
|
||||
<th data-sort-key="name">Name</th>
|
||||
<th data-sort-key="username">Username</th>
|
||||
@@ -171,6 +177,14 @@ function openEditCredentialModal(id, name, username, category) {
|
||||
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);
|
||||
@@ -50,9 +50,9 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for d in devices %}
|
||||
<tr data-sort-hostname="{{ d['name']|lower }}" data-sort-ip="{{ d['rpi_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 }}">
|
||||
<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['rpi_ip'] }}</td>
|
||||
<td class="mono">{{ d['ip'] }}</td>
|
||||
<td class="mono">{{ d['mac'] }}</td>
|
||||
<td>{{ d['switch_hostname'] or '—' }}</td>
|
||||
<td>{{ d['port'] or '—' }}</td>
|
||||
@@ -76,7 +76,7 @@
|
||||
<div class="row-actions">
|
||||
{% if can_edit %}
|
||||
<button class="icon-btn" title="Bearbeiten"
|
||||
onclick="openEditDeviceModal('{{ d['mac'] }}','{{ d['name'] }}','{{ d['rpi_ip'] }}','{{ d['port'] or '' }}','{{ d['ssh_port'] or '' }}','{{ d['credential_id'] or '' }}')">
|
||||
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"
|
||||
@@ -119,7 +119,7 @@
|
||||
<input type="text" name="name" required placeholder="z.B. Sensor01">
|
||||
</div>
|
||||
<div class="field"><label>IP-Adresse</label>
|
||||
<input type="text" name="rpi_ip" required placeholder="z.B. 192.168.1.100">
|
||||
<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>
|
||||
@@ -196,7 +196,7 @@
|
||||
<input type="text" name="name" id="edit_name" required>
|
||||
</div>
|
||||
<div class="field"><label>IP-Adresse</label>
|
||||
<input type="text" name="rpi_ip" id="edit_ip" required>
|
||||
<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>
|
||||
@@ -361,7 +361,7 @@ function validateNewCredentialPassword(form) {
|
||||
return true;
|
||||
}
|
||||
function validateDeviceForm(form) {
|
||||
const ipInput = form.querySelector("input[name='rpi_ip']");
|
||||
const ipInput = form.querySelector("input[name='ip']");
|
||||
const macInput = form.querySelector("input[name='mac']");
|
||||
let valid = true;
|
||||
if (ipInput) valid = validateIP(ipInput) && valid;
|
||||
@@ -370,7 +370,7 @@ function validateDeviceForm(form) {
|
||||
return valid;
|
||||
}
|
||||
document.addEventListener("input", (e) => {
|
||||
if (e.target.name === "rpi_ip") validateIP(e.target);
|
||||
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");
|
||||
@@ -395,7 +395,7 @@ function resetCredentialChoice(id) {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SSH-Verbindungstest (Web-Terminal via /ws/ssh_terminal) — identisch zum
|
||||
// Muster in switches.html, nur mit rpi_ip statt ip als Feldname.
|
||||
// Identisches Muster wie in switches.html.
|
||||
// -------------------------------------------------------------------------
|
||||
let term = null, fitAddon = null, termSocket = null, activePasswordInput = null;
|
||||
|
||||
@@ -444,7 +444,7 @@ function getCredentialInfo(form) {
|
||||
}
|
||||
|
||||
function openTerminal(form) {
|
||||
const host = (form.querySelector("input[name='rpi_ip']") || {}).value?.trim();
|
||||
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;
|
||||
@@ -82,8 +82,9 @@
|
||||
</div>
|
||||
<div class="permission-legend">
|
||||
<strong>R</strong> = Read (Lesen) · <strong>W</strong> = Write (Anlegen) ·
|
||||
<strong>E</strong> = Edit (Ändern, inkl. Löschen) ·
|
||||
<strong>D</strong> = Dashboard (PoE-Neustart über das Dashboard-Popup, nur bei Clients)
|
||||
<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 %}
|
||||
|
||||
@@ -104,8 +105,14 @@
|
||||
</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" data-sortable>
|
||||
<table class="data-table" id="groupsTable" data-sortable>
|
||||
<thead><tr>
|
||||
<th data-sort-key="name">Gruppe</th>
|
||||
<th data-sort-key="members">Mitglieder</th>
|
||||
@@ -168,6 +175,7 @@
|
||||
</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 %}
|
||||
@@ -184,6 +192,32 @@
|
||||
</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 %}
|
||||
@@ -295,6 +329,31 @@
|
||||
|
||||
{% 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;
|
||||
@@ -329,6 +388,33 @@ function applyPermissionGating() {
|
||||
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 %}
|
||||
@@ -3,18 +3,23 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Login · PoE Manager</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='images/logo.png') }}">
|
||||
<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>
|
||||
<h1>PoE Manager</h1>
|
||||
<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() %}
|
||||
@@ -26,8 +26,14 @@
|
||||
|
||||
<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" data-sortable>
|
||||
<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>
|
||||
@@ -39,12 +45,12 @@
|
||||
<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['rpi_ip'] }}" data-sort-user="{{ d['cred_username']|lower }}">
|
||||
<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['rpi_ip'] }}:{{ d['ssh_port'] or 22 }}</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 %}
|
||||
@@ -62,7 +68,7 @@
|
||||
<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['rpi_ip'] }}) jetzt per SSH neu starten?">
|
||||
<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>
|
||||
@@ -103,6 +109,17 @@ document.getElementById("selectAll")?.addEventListener("change", function () {
|
||||
}, 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";
|
||||
@@ -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 %}
|
||||
@@ -40,6 +40,18 @@
|
||||
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>
|
||||
@@ -49,6 +61,11 @@
|
||||
<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>
|
||||
|
||||
@@ -192,30 +209,98 @@
|
||||
<div class="card card-pad">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Prüfintervall</h2>
|
||||
<div class="hint">Wie oft sollen Geräte auf Erreichbarkeit geprüft werden?</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="interval">Intervall (Minuten)</label>
|
||||
<input type="number" name="interval" id="interval" value="{{ interval }}" min="1" required>
|
||||
<div class="field-hint">Der Hintergrund-Dienst (rpi-check.service) wird nach dem Speichern automatisch neu gestartet.</div>
|
||||
<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" class="btn btn-primary btn-block">
|
||||
<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 & Service neustarten
|
||||
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>Intervall (Minuten)</label>
|
||||
<input type="number" value="{{ interval }}" disabled>
|
||||
<div class="field-hint">Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“.</div>
|
||||
<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 %}
|
||||
@@ -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 & 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 %}
|
||||
@@ -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>×</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 %}
|
||||
@@ -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>×</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>×</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>×</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>×</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 %}
|
||||