Live-Log-Fix, Sidebar-Untermenüs für Geräte/Einstellungen, eigenes Konto
- Live-Log-Bug behoben: _latest_log_file() sortierte nach Datei-ctime statt
nach Dateiname — auf manchen Dateisystemen (u.a. unter WSL2 auf einem
gemounteten Windows-Laufwerk) unzuverlässig und lieferte nicht immer das
tatsächlich neueste Logfile. Sortiert jetzt wie der Rest des Codes über
den chronologisch sortierbaren Dateinamen (rpi-YYYYMMDDHHMMSS.log).
- Navbar umstrukturiert in aufklappbare Gruppen mit Unterpunkten:
- "Geräte": Clients (bisherige Devices-Seite), Switche, Zugangsdaten
- "Einstellungen": Benutzer, Gruppen, Systemeinstellungen (Prüfintervall),
Im-/Export
- "Logs": Live, Änderungen (vormals Live-Log/Änderungslog)
Sichtbarkeit gilt jetzt auch pro Unterpunkt: eine Gruppe erscheint nur,
wenn mindestens ein Unterpunkt für den Benutzer sichtbar ist, und zeigt
dann auch nur die sichtbaren Unterpunkte.
- Im-/Export als eigene Unterseite mit Export/Import nebeneinander
(.settings-grid).
- Neue Seite "Mein Konto" (/account, erreichbar über ein Zahnrad-Symbol
neben dem eigenen Namen in der Sidebar) ersetzt das bisherige Profil-Modal:
Profil/Passwort/Profilbild als volle Seite, dazu für Admins die
Navbar-Reihenfolge (aus den Systemeinstellungen hierher verschoben).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+64
-26
@@ -150,36 +150,43 @@ DEFAULT_GROUP_NAME = "Benutzer"
|
||||
DEFAULT_GROUP_PERMISSIONS = ["devices.view", "switches.view"]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Navbar — Reihenfolge ist admin-konfigurierbar (siehe settings(), gespeichert
|
||||
# als JSON-Liste von Keys unter settings.nav_order); standardmäßig wird pro
|
||||
# Benutzer nur angezeigt, wofür er auch tatsächlich eine Berechtigung hat.
|
||||
# "logs" ist eine Gruppe mit Untermenü (Live-Log + Änderungslog).
|
||||
# Navbar — die Reihenfolge der TOP-LEVEL-Punkte ist admin-konfigurierbar
|
||||
# (siehe /account, gespeichert als JSON-Liste von Keys unter
|
||||
# settings.nav_order); standardmäßig wird pro Benutzer nur angezeigt, wofür
|
||||
# er auch tatsächlich eine Berechtigung hat — das gilt auch pro Unterpunkt
|
||||
# innerhalb einer Gruppe: eine Gruppe erscheint nur, wenn mindestens ein
|
||||
# Unterpunkt sichtbar ist, und zeigt dann auch nur die sichtbaren Unterpunkte.
|
||||
# ---------------------------------------------------------------------------
|
||||
NAV_ITEMS = [
|
||||
{"key": "index", "label": "Dashboard", "icon": "grid", "endpoint": "index"},
|
||||
{"key": "devices", "label": "Geräte", "icon": "cpu", "endpoint": "devices"},
|
||||
{"key": "switches", "label": "Switche", "icon": "share", "endpoint": "switches"},
|
||||
{"key": "credentials", "label": "Zugangsdaten", "icon": "key", "endpoint": "credentials"},
|
||||
{"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"},
|
||||
{"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"},
|
||||
{"key": "logs", "label": "Logs", "icon": "terminal", "children": [
|
||||
{"key": "logs_live", "label": "Live-Log", "icon": "terminal", "endpoint": "logs"},
|
||||
{"key": "logs_activity", "label": "Änderungslog", "icon": "history", "endpoint": "activity_log"},
|
||||
{"key": "devices_group", "label": "Geräte", "icon": "cpu", "children": [
|
||||
{"key": "devices", "label": "Clients", "icon": "cpu", "endpoint": "devices"},
|
||||
{"key": "switches", "label": "Switche", "icon": "share", "endpoint": "switches"},
|
||||
{"key": "credentials", "label": "Zugangsdaten", "icon": "key", "endpoint": "credentials"},
|
||||
]},
|
||||
{"key": "settings_group", "label": "Einstellungen", "icon": "sliders", "children": [
|
||||
{"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"},
|
||||
{"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"},
|
||||
{"key": "settings_system", "label": "Systemeinstellungen", "icon": "sliders", "endpoint": "settings"},
|
||||
{"key": "settings_importexport", "label": "Im-/Export", "icon": "transfer", "endpoint": "settings_import_export"},
|
||||
]},
|
||||
{"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [
|
||||
{"key": "logs_live", "label": "Live", "icon": "terminal", "endpoint": "logs"},
|
||||
{"key": "logs_activity", "label": "Änderungen", "icon": "history", "endpoint": "activity_log"},
|
||||
]},
|
||||
{"key": "settings", "label": "Einstellungen", "icon": "sliders", "endpoint": "settings"},
|
||||
]
|
||||
DEFAULT_NAV_ORDER = [item["key"] for item in NAV_ITEMS]
|
||||
NAV_ITEMS_BY_KEY = {item["key"]: item for item in NAV_ITEMS}
|
||||
|
||||
|
||||
def _nav_item_visible(key, user):
|
||||
def _nav_key_visible(key, user):
|
||||
if key == "index":
|
||||
return True
|
||||
if key == "devices":
|
||||
return user.can_view_devices
|
||||
if key in ("switches", "credentials"):
|
||||
return user.can_manage_switches
|
||||
if key in ("users", "groups", "logs", "settings"):
|
||||
if key in ("users", "groups", "settings_system", "settings_importexport", "logs_live", "logs_activity"):
|
||||
return user.is_admin
|
||||
return False
|
||||
|
||||
@@ -194,7 +201,16 @@ def inject_nav():
|
||||
stored_order = []
|
||||
ordered_keys = [k for k in stored_order if k in NAV_ITEMS_BY_KEY]
|
||||
ordered_keys += [k for k in DEFAULT_NAV_ORDER if k not in ordered_keys]
|
||||
nav_items_ordered = [NAV_ITEMS_BY_KEY[k] for k in ordered_keys if _nav_item_visible(k, current_user)]
|
||||
|
||||
nav_items_ordered = []
|
||||
for k in ordered_keys:
|
||||
item = NAV_ITEMS_BY_KEY[k]
|
||||
if item.get("children"):
|
||||
visible_children = [c for c in item["children"] if _nav_key_visible(c["key"], current_user)]
|
||||
if visible_children:
|
||||
nav_items_ordered.append({**item, "children": visible_children})
|
||||
elif _nav_key_visible(k, current_user):
|
||||
nav_items_ordered.append(item)
|
||||
return {"nav_items_ordered": nav_items_ordered}
|
||||
|
||||
|
||||
@@ -507,12 +523,19 @@ def logout():
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Eigenes Profil — jeder eingeloggte Benutzer darf Vor-/Nachname, eigenes
|
||||
# Passwort und Profilbild selbst ändern (Klick auf den eigenen Namen in der
|
||||
# Sidebar). Bewusst getrennt vom Admin-"Users"-Bereich, der andere Benutzer
|
||||
# verwaltet.
|
||||
# Eigenes Profil / Konto — jeder eingeloggte Benutzer darf Vor-/Nachname,
|
||||
# eigenes Passwort und Profilbild selbst ändern (Zahnrad-Symbol neben dem
|
||||
# eigenen Namen unten in der Sidebar). Bewusst getrennt vom Admin-"Users"-
|
||||
# Bereich, der andere Benutzer verwaltet. Admins konfigurieren hier
|
||||
# zusätzlich die Navbar-Reihenfolge (siehe save_nav_order()).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.route("/account")
|
||||
@login_required
|
||||
def account():
|
||||
return render_template("account.html")
|
||||
|
||||
|
||||
@app.route("/profile", methods=["POST"])
|
||||
@login_required
|
||||
def profile():
|
||||
@@ -576,10 +599,16 @@ def profile():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _latest_log_file():
|
||||
"""Neuestes Logfile ermitteln. Bewusst über den Dateinamen sortiert
|
||||
(rpi-YYYYMMDDHHMMSS.log ist lexikografisch = chronologisch sortierbar),
|
||||
NICHT über Dateisystem-Metadaten wie ctime/mtime — die sind je nach
|
||||
Dateisystem (z.B. unter WSL2 auf einem gemounteten Windows-Laufwerk)
|
||||
unzuverlässig und haben in der Praxis nicht immer das tatsächlich
|
||||
zuletzt geschriebene Logfile geliefert."""
|
||||
log_files = glob.glob(LOG_GLOB)
|
||||
if not log_files:
|
||||
return None
|
||||
return max(log_files, key=os.path.getctime)
|
||||
return max(log_files)
|
||||
|
||||
|
||||
def get_last_seen(dev_name: str):
|
||||
@@ -758,6 +787,15 @@ def settings():
|
||||
return render_template("settings.html", interval=interval)
|
||||
|
||||
|
||||
@app.route("/settings/import-export")
|
||||
@login_required
|
||||
def settings_import_export():
|
||||
if not current_user.is_admin:
|
||||
flash("Nur Admins dürfen Daten importieren/exportieren!", "danger")
|
||||
return redirect(url_for("index"))
|
||||
return render_template("settings_import_export.html")
|
||||
|
||||
|
||||
@app.route("/settings/nav-order", methods=["POST"])
|
||||
@login_required
|
||||
def save_nav_order():
|
||||
@@ -770,7 +808,7 @@ def save_nav_order():
|
||||
set_setting("nav_order", json.dumps(order))
|
||||
log_action("settings.update", "Navbar-Reihenfolge")
|
||||
flash("Navbar-Reihenfolge gespeichert.", "success")
|
||||
return redirect(url_for("settings"))
|
||||
return redirect(url_for("account"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -791,7 +829,7 @@ def export_data():
|
||||
passphrase = request.form.get("export_passphrase", "")
|
||||
if not passphrase:
|
||||
flash("Bitte eine Passphrase für den Export angeben.", "danger")
|
||||
return redirect(url_for("settings"))
|
||||
return redirect(url_for("settings_import_export"))
|
||||
|
||||
conn = get_db_connection()
|
||||
credentials_rows = conn.execute("SELECT name, username, password FROM credentials").fetchall()
|
||||
@@ -850,7 +888,7 @@ def import_data():
|
||||
upload = request.files.get("import_file")
|
||||
if not passphrase or not upload or not upload.filename:
|
||||
flash("Bitte Passphrase und Export-Datei angeben.", "danger")
|
||||
return redirect(url_for("settings"))
|
||||
return redirect(url_for("settings_import_export"))
|
||||
|
||||
try:
|
||||
envelope = json.loads(upload.read().decode("utf-8"))
|
||||
@@ -859,7 +897,7 @@ def import_data():
|
||||
payload = json.loads(export_fernet.decrypt(envelope["payload"].encode("utf-8")).decode("utf-8"))
|
||||
except Exception:
|
||||
flash("Import fehlgeschlagen: Datei ungültig oder Passphrase falsch.", "danger")
|
||||
return redirect(url_for("settings"))
|
||||
return redirect(url_for("settings_import_export"))
|
||||
|
||||
conn = get_db_connection()
|
||||
n_cred = n_switch = n_dev = 0
|
||||
@@ -898,7 +936,7 @@ def import_data():
|
||||
|
||||
log_action("data.import", "Devices/Switches/Zugangsdaten", f"{n_dev} Geräte, {n_switch} Switche, {n_cred} Zugangsdaten")
|
||||
flash(f"Import abgeschlossen: {n_cred} Zugangsdaten, {n_switch} Switche, {n_dev} Geräte.", "success")
|
||||
return redirect(url_for("settings"))
|
||||
return redirect(url_for("settings_import_export"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -200,6 +200,14 @@ button { font-family: inherit; }
|
||||
.nav-group.expanded .nav-group-children { display: flex; }
|
||||
.nav-group-children .nav-item { font-size: 13px; padding: 8px 12px; }
|
||||
|
||||
/* Kacheln nebeneinander (z.B. Im-/Export, Konto-Seite) */
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* Navbar-Reihenfolge (Settings) */
|
||||
.nav-order-list {
|
||||
list-style: none;
|
||||
@@ -247,7 +255,9 @@ button { font-family: inherit; }
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-meta { overflow: hidden; }
|
||||
.user-meta { flex: 1; min-width: 0; overflow: hidden; }
|
||||
.user-chip .icon-btn { flex-shrink: 0; width: 30px; height: 30px; }
|
||||
.user-chip .icon-btn svg { width: 15px; height: 15px; }
|
||||
.user-meta .u-name { font-size: 13px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.user-meta .u-role { font-size: 11px; color: var(--text-faint); }
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_title %}Mein Konto{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ current_user.username }}</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% if current_user.is_admin %}
|
||||
<div class="card card-pad" style="margin-bottom:24px;">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Navbar-Reihenfolge</h2>
|
||||
<div class="hint">Bestimmt, in welcher Reihenfolge die Menüpunkte in der Sidebar erscheinen. Jeder Benutzer sieht davon nur, wofür er auch berechtigt ist.</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('save_nav_order') }}" id="navOrderForm">
|
||||
<ul class="nav-order-list" id="navOrderList">
|
||||
{% for item in nav_items_ordered %}
|
||||
<li data-key="{{ item.key }}">
|
||||
<span>{{ item.label }}</span>
|
||||
<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>
|
||||
<input type="hidden" name="nav_order" value="{{ item.key }}">
|
||||
</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 class="settings-grid">
|
||||
|
||||
<div class="card card-pad">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Profil</h2>
|
||||
<div class="hint">Name und Profilbild ändern sich in der Sidebar und im Änderungslog.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; align-items:center; gap:14px; margin-bottom:20px;">
|
||||
{% if current_user.avatar_url %}
|
||||
<img src="{{ current_user.avatar_url }}" alt="" style="width:56px; height:56px; border-radius:50%; object-fit:cover;">
|
||||
{% else %}
|
||||
<div class="user-avatar" style="width:56px; height:56px; font-size:18px;">{{ current_user.username[:2]|upper }}</div>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('profile') }}" enctype="multipart/form-data" id="avatarForm" style="flex:1;">
|
||||
<input type="file" name="avatar" id="avatarInput" accept="image/png,image/jpeg,image/gif,image/webp" style="display:none;" onchange="document.getElementById('avatarForm').requestSubmit();">
|
||||
<button type="button" class="btn btn-secondary" style="width:100%;" onclick="document.getElementById('avatarInput').click();">Profilbild ändern</button>
|
||||
<input type="hidden" name="upload_avatar" value="1">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('profile') }}" id="profileForm">
|
||||
<div class="field"><label>Vorname</label><input type="text" name="first_name" value="{{ current_user.first_name or '' }}"></div>
|
||||
<div class="field"><label>Name</label><input type="text" name="last_name" value="{{ current_user.last_name or '' }}"></div>
|
||||
<div class="field"><label>Username</label><input type="text" value="{{ current_user.username }}" disabled></div>
|
||||
<button type="submit" name="update_profile" value="1" class="btn btn-primary btn-block">Speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card card-pad">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Passwort ändern</h2>
|
||||
<div class="hint">Erfordert Eingabe des aktuellen Passworts.</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
</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 %}
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "logs" %}
|
||||
{% block page_title %}Änderungslog{% endblock %}
|
||||
{% block page_title %}Änderungen{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ entries|length }} Einträge (letzte 500)</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
"history": '<path d="M3 12a9 9 0 109-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
|
||||
"sliders": '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h13M20 18h0"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>',
|
||||
"logout": '<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/>',
|
||||
"gear": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>',
|
||||
"transfer": '<path d="M17 3l4 4-4 4"/><path d="M3 7h18"/><path d="M7 21l-4-4 4-4"/><path d="M21 17H3"/>',
|
||||
} %}
|
||||
|
||||
<div class="app-shell">
|
||||
@@ -65,7 +67,7 @@
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button type="button" class="user-chip" data-open-modal="profileModal" onclick="resetProfileForms();" style="cursor:pointer; text-align:left; background:none; border:none; width:100%;">
|
||||
<div class="user-chip">
|
||||
{% if current_user.avatar_url %}
|
||||
<img class="user-avatar" src="{{ current_user.avatar_url }}" alt="" style="object-fit:cover;">
|
||||
{% else %}
|
||||
@@ -75,7 +77,10 @@
|
||||
<div class="u-name">{{ current_user.display_name }}</div>
|
||||
<div class="u-role">{{ "Administrator" if current_user.is_admin else current_user.group_names or "Benutzer" }}</div>
|
||||
</div>
|
||||
</button>
|
||||
<a href="{{ url_for('account') }}" class="icon-btn" title="Einstellungen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['gear']|safe }}</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div class="footer-actions">
|
||||
<button type="button" class="icon-btn" data-theme-toggle title="Theme wechseln">
|
||||
<span data-theme-icon></span>
|
||||
@@ -86,49 +91,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Modal: Mein Profil (Klick auf eigenen Namen in der Sidebar) -->
|
||||
<div class="modal-overlay" id="profileModal">
|
||||
<div class="modal" style="max-width:440px;">
|
||||
<div class="modal-header">
|
||||
<h3>Mein Profil</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
<div style="display:flex; align-items:center; gap:14px; margin-bottom:20px;">
|
||||
{% if current_user.avatar_url %}
|
||||
<img src="{{ current_user.avatar_url }}" alt="" style="width:56px; height:56px; border-radius:50%; object-fit:cover;">
|
||||
{% else %}
|
||||
<div class="user-avatar" style="width:56px; height:56px; font-size:18px;">{{ current_user.username[:2]|upper }}</div>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('profile') }}" enctype="multipart/form-data" id="avatarForm" style="flex:1;">
|
||||
<input type="file" name="avatar" id="avatarInput" accept="image/png,image/jpeg,image/gif,image/webp" style="display:none;" onchange="document.getElementById('avatarForm').requestSubmit();">
|
||||
<button type="button" class="btn btn-secondary" style="width:100%;" onclick="document.getElementById('avatarInput').click();">Profilbild ändern</button>
|
||||
<input type="hidden" name="upload_avatar" value="1">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('profile') }}" id="profileForm">
|
||||
<div class="field"><label>Vorname</label><input type="text" name="first_name" value="{{ current_user.first_name or '' }}"></div>
|
||||
<div class="field"><label>Name</label><input type="text" name="last_name" value="{{ current_user.last_name or '' }}"></div>
|
||||
<div class="field"><label>Username</label><input type="text" value="{{ current_user.username }}" disabled></div>
|
||||
<button type="submit" name="update_profile" value="1" class="btn btn-primary btn-block">Speichern</button>
|
||||
</form>
|
||||
|
||||
<div style="border-top:1px solid var(--border); margin:20px 0;"></div>
|
||||
|
||||
<form method="post" action="{{ url_for('profile') }}" id="passwordForm">
|
||||
<h4 style="font-size:13px; text-transform:uppercase; letter-spacing:.04em; color:var(--text-dim); margin-bottom:12px;">Passwort ändern</h4>
|
||||
<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>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="main {% if not current_user.is_authenticated %}no-sidebar{% endif %}">
|
||||
@@ -171,12 +133,6 @@
|
||||
{% endwith %}
|
||||
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
<script>
|
||||
function resetProfileForms() {
|
||||
const pwForm = document.getElementById("passwordForm");
|
||||
if (pwForm) pwForm.reset();
|
||||
}
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% set can_delete = current_user.has_permission('devices.delete') %}
|
||||
{% set show_actions_col = can_edit or can_delete %}
|
||||
{% set col_count = 5 + (1 if can_toggle else 0) + (1 if show_actions_col else 0) %}
|
||||
{% block page_title %}Geräte{% endblock %}
|
||||
{% block page_title %}Clients{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte</div>{% endblock %}
|
||||
{% block topbar_right %}
|
||||
{% if can_create %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "logs" %}
|
||||
{% block page_title %}Live-Log{% endblock %}
|
||||
{% block page_title %}Live{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ log_name or "kein Logfile" }}</div>{% endblock %}
|
||||
{% block topbar_right %}
|
||||
<span class="timer-pill"><span class="dot"></span>Update in <span id="timer">--</span>s</span>
|
||||
|
||||
@@ -1,41 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "settings" %}
|
||||
{% block page_title %}Einstellungen{% endblock %}
|
||||
{% set active_page = "settings_system" %}
|
||||
{% block page_title %}Systemeinstellungen{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">Prüfintervall für das Monitoring</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card card-pad" style="max-width:420px;">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Navbar-Reihenfolge</h2>
|
||||
<div class="hint">Bestimmt, in welcher Reihenfolge die Menüpunkte in der Sidebar erscheinen. Jeder Benutzer sieht davon nur, wofür er auch berechtigt ist.</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('save_nav_order') }}" id="navOrderForm">
|
||||
<ul class="nav-order-list" id="navOrderList">
|
||||
{% for item in nav_items_ordered %}
|
||||
<li data-key="{{ item.key }}">
|
||||
<span>{{ item.label }}</span>
|
||||
<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>
|
||||
<input type="hidden" name="nav_order" value="{{ item.key }}">
|
||||
</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>
|
||||
|
||||
<div class="card card-pad" style="max-width:420px; margin-top:24px;">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Prüfintervall</h2>
|
||||
@@ -54,60 +23,4 @@
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card card-pad" style="max-width:420px; margin-top:24px;">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Export</h2>
|
||||
<div class="hint">Geräte, Switche und Zugangsdaten als verschlüsseltes Bundle sichern — z.B. für einen Umzug auf eine neue Umgebung.</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" style="max-width:420px; margin-top:24px;">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Import</h2>
|
||||
<div class="hint">Ein zuvor exportiertes Bundle einlesen. Bestehende Einträge mit gleichem Namen/Hostname/MAC werden aktualisiert, neue werden angelegt.</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</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,54 @@
|
||||
{% 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 als verschlüsseltes Bundle sichern — z.B. für einen Umzug auf eine neue Umgebung.</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">Ein zuvor exportiertes Bundle einlesen. Bestehende Einträge mit gleichem Namen/Hostname/MAC werden aktualisiert, neue werden angelegt.</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user