Compare commits
14
Commits
v1.1.0
...
8577cd24cd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8577cd24cd | ||
|
|
877903bd77 | ||
|
|
2b05d012ee | ||
|
|
0f0fa99e53 | ||
|
|
9b4cf8c2e1 | ||
|
|
1c0082d3d4 | ||
|
|
fc4b165d23 | ||
|
|
e81735d5ec | ||
|
|
2eaefc9e1f | ||
|
|
cb8b929c3c | ||
|
|
af5dfe30b4 | ||
|
|
efb0d2aa01 | ||
|
|
d461f8ca42 | ||
|
|
1b7fe81507 |
@@ -29,6 +29,11 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
# nginx' Standard (1m) reicht für Fileshare-Uploads nicht -- etwas
|
||||||
|
# großzügiger als Flasks eigenes MAX_CONTENT_LENGTH (siehe app.py),
|
||||||
|
# damit bei einer knapp 15MB großen Datei nginx nicht schon vor
|
||||||
|
# Flask mit seiner eigenen, unschöneren 413-Seite abbricht.
|
||||||
|
client_max_body_size 16m;
|
||||||
proxy_pass http://127.0.0.1:5000;
|
proxy_pass http://127.0.0.1:5000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ sudo chmod 755 /var/log/tesm
|
|||||||
step "Deploying application to /srv/tesm"
|
step "Deploying application to /srv/tesm"
|
||||||
sudo mkdir -p /srv/tesm
|
sudo mkdir -p /srv/tesm
|
||||||
sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \
|
sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \
|
||||||
|
--exclude 'known_hosts' \
|
||||||
"$REPO_DIR/srv/tesm/" /srv/tesm/ >>/var/log/tesm-install.log 2>&1
|
"$REPO_DIR/srv/tesm/" /srv/tesm/ >>/var/log/tesm-install.log 2>&1
|
||||||
print_status "Application files copied"
|
print_status "Application files copied"
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.1.0
|
1.1.11
|
||||||
|
|||||||
+803
-62
File diff suppressed because it is too large
Load Diff
@@ -446,6 +446,18 @@ button { font-family: inherit; }
|
|||||||
|
|
||||||
.card-pad { padding: 20px 22px; }
|
.card-pad { padding: 20px 22px; }
|
||||||
|
|
||||||
|
.notice-banner {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.notice-banner--warning {
|
||||||
|
background: var(--warning-dim);
|
||||||
|
color: var(--warning);
|
||||||
|
border: 1px solid var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
.section-head {
|
.section-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -498,6 +510,7 @@ button { font-family: inherit; }
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.pill svg { width: 12px; height: 12px; }
|
.pill svg { width: 12px; height: 12px; }
|
||||||
.pill::before {
|
.pill::before {
|
||||||
@@ -516,6 +529,11 @@ button { font-family: inherit; }
|
|||||||
.pill.action-pill { background: var(--muted-dim); color: var(--text-dim); }
|
.pill.action-pill { background: var(--muted-dim); color: var(--text-dim); }
|
||||||
.pill.action-pill::before { display: none; }
|
.pill.action-pill::before { display: none; }
|
||||||
.pill.action-pill svg { width: 13px; height: 13px; }
|
.pill.action-pill svg { width: 13px; height: 13px; }
|
||||||
|
/* Auditlog: Hinzufuegen gruen, Loeschen rot, alles andere (Bearbeiten/
|
||||||
|
Aktivieren/Zuweisen/...) orange -- siehe activity_log.html */
|
||||||
|
.pill.action-pill--create { background: var(--success-dim); color: var(--success); }
|
||||||
|
.pill.action-pill--delete { background: var(--danger-dim); color: var(--danger); }
|
||||||
|
.pill.action-pill--edit { background: var(--accent-dim); color: var(--accent-strong); }
|
||||||
|
|
||||||
.avatar-sm {
|
.avatar-sm {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -950,6 +968,48 @@ select {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
animation: modal-in 0.16s ease;
|
animation: modal-in 0.16s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Hinweis-Icons (Hover-Tooltip + Klick-Modal), siehe _hint_icon.html
|
||||||
|
und initHintIcons() in app.js ---- */
|
||||||
|
.hint-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid var(--text-faint);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: italic;
|
||||||
|
font-family: Georgia, "Times New Roman", serif;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: help;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 0;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.hint-icon:hover, .hint-icon:focus-visible { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|
||||||
|
#hint-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10000;
|
||||||
|
max-width: 280px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
pointer-events: none;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@keyframes modal-in {
|
@keyframes modal-in {
|
||||||
from { opacity: 0; transform: translateY(8px) scale(0.98); }
|
from { opacity: 0; transform: translateY(8px) scale(0.98); }
|
||||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
@@ -1146,6 +1206,31 @@ select {
|
|||||||
}
|
}
|
||||||
[data-theme="light"] .raw-log-content { background: #0e1116; }
|
[data-theme="light"] .raw-log-content { background: #0e1116; }
|
||||||
|
|
||||||
|
/* Zeilennummern im RAW-Log-Popup (aktuell nur "Verlauf") -- Nummer kommt
|
||||||
|
rein aus CSS-Countern (kein Extra-Markup pro Zeile mit fest eingebrannter
|
||||||
|
Zahl), damit Filtern/Kopieren des reinen Logtexts unverändert bleibt. */
|
||||||
|
.raw-log-content.with-line-numbers {
|
||||||
|
counter-reset: raw-line;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
.raw-log-content.with-line-numbers .raw-log-line {
|
||||||
|
display: flex;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.raw-log-content.with-line-numbers .raw-log-line::before {
|
||||||
|
counter-increment: raw-line;
|
||||||
|
content: counter(raw-line);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 3.5em;
|
||||||
|
margin-right: 14px;
|
||||||
|
padding-right: 10px;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
text-align: right;
|
||||||
|
color: var(--text-faint);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
Permission checklists (Gruppen)
|
Permission checklists (Gruppen)
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
@@ -1292,6 +1377,92 @@ select {
|
|||||||
}
|
}
|
||||||
.xterm-container .xterm { height: 100%; }
|
.xterm-container .xterm { height: 100%; }
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Fileshare (Baum-Navigation + Vorschau)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* align-items:stretch (statt flex-start) + die main-Seite selbst als
|
||||||
|
Flex-Spalte mit table-wrap{flex:1}, damit beide Kacheln (Baum links,
|
||||||
|
Tabelle rechts) immer gleich hoch sind, unabhängig davon welche Seite
|
||||||
|
gerade mehr Inhalt hat. */
|
||||||
|
.fileshare-layout { display: flex; align-items: stretch; gap: 16px; }
|
||||||
|
.fileshare-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
.fileshare-main .table-wrap { flex: 1; }
|
||||||
|
|
||||||
|
.fileshare-tree {
|
||||||
|
flex: 0 0 260px;
|
||||||
|
max-width: 260px;
|
||||||
|
padding: 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.fileshare-tree-title {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-weight: 650;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-root, .tree-children { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.tree-children { padding-left: 16px; }
|
||||||
|
|
||||||
|
.tree-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
.tree-row:hover { background: var(--bg-card-hover); }
|
||||||
|
.tree-row.active { background: var(--bg-card-hover); color: var(--accent); font-weight: 600; }
|
||||||
|
|
||||||
|
.tree-toggle {
|
||||||
|
width: 18px; height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
border: none; background: transparent; color: var(--text-faint);
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.tree-toggle:hover { color: var(--text); }
|
||||||
|
.tree-toggle:disabled { visibility: hidden; }
|
||||||
|
|
||||||
|
.tree-label {
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vorschau: von mammoth.js/SheetJS erzeugter bzw. selbst gebauter Inhalt */
|
||||||
|
.docx-preview { font-size: 14px; line-height: 1.65; }
|
||||||
|
.docx-preview table { border-collapse: collapse; margin: 10px 0; }
|
||||||
|
.docx-preview table td, .docx-preview table th { border: 1px solid var(--border); padding: 6px 10px; }
|
||||||
|
.docx-preview img { max-width: 100%; }
|
||||||
|
.xlsx-preview-sheet-title { margin: 20px 0 8px; font-size: 13px; font-weight: 650; }
|
||||||
|
.xlsx-preview-sheet-title:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
/* Angesammelte Dateien im Upload-Modal (Mehrfachauswahl, siehe fileshare.html) */
|
||||||
|
.upload-file-list { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.upload-file-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.upload-file-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.upload-file-remove {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: none; background: transparent; color: var(--text-faint);
|
||||||
|
font-size: 16px; line-height: 1; cursor: pointer; padding: 0 2px;
|
||||||
|
}
|
||||||
|
.upload-file-remove:hover { color: var(--danger); }
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
Utilities
|
Utilities
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
@@ -1336,6 +1507,12 @@ select {
|
|||||||
Buttons/Suchfeld ineinanderzuschieben. */
|
Buttons/Suchfeld ineinanderzuschieben. */
|
||||||
.modal-footer { flex-wrap: wrap; }
|
.modal-footer { flex-wrap: wrap; }
|
||||||
.table-toolbar .search-input { min-width: 0; flex: 1 1 160px; }
|
.table-toolbar .search-input { min-width: 0; flex: 1 1 160px; }
|
||||||
|
|
||||||
|
/* Fileshare-Baum + Tabelle nebeneinander sprengt auf Tablet-/Handy-
|
||||||
|
Breite die Seite (Baum-Spalte ist fest 260px breit) -- Baum stapelt
|
||||||
|
stattdessen oben, Tabelle darunter in voller Breite. */
|
||||||
|
.fileshare-layout { flex-direction: column; }
|
||||||
|
.fileshare-tree { flex: 1 1 auto; max-width: 100%; max-height: 240px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
|||||||
+120
-2
@@ -245,12 +245,30 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.openRawLogModal = function (url, title) {
|
function renderRawLogLines(container, text) {
|
||||||
|
// Eine Zeile = ein <div>, Nummer kommt per CSS-Counter (::before) --
|
||||||
|
// so bleibt der eigentliche Zeileninhalt reiner textContent (kein XSS-
|
||||||
|
// Risiko) und die Nummerierung muss nirgends von Hand mitgezählt werden.
|
||||||
|
const lines = text.split("\n");
|
||||||
|
if (lines.length && lines[lines.length - 1] === "") lines.pop(); // trailing \n erzeugt sonst eine Phantomzeile
|
||||||
|
container.textContent = "";
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
lines.forEach((line) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "raw-log-line";
|
||||||
|
row.textContent = line;
|
||||||
|
frag.appendChild(row);
|
||||||
|
});
|
||||||
|
container.appendChild(frag);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.openRawLogModal = function (url, title, withLineNumbers) {
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
ensureRawLogModal();
|
ensureRawLogModal();
|
||||||
document.getElementById("raw-log-modal-title").innerText = title || "Komplettes Log (RAW)";
|
document.getElementById("raw-log-modal-title").innerText = title || "Komplettes Log (RAW)";
|
||||||
const content = document.getElementById("raw-log-modal-content");
|
const content = document.getElementById("raw-log-modal-content");
|
||||||
const meta = document.getElementById("raw-log-modal-meta");
|
const meta = document.getElementById("raw-log-modal-meta");
|
||||||
|
content.classList.toggle("with-line-numbers", !!withLineNumbers);
|
||||||
content.textContent = "Lade …";
|
content.textContent = "Lade …";
|
||||||
meta.textContent = "";
|
meta.textContent = "";
|
||||||
openModal("raw-log-modal");
|
openModal("raw-log-modal");
|
||||||
@@ -260,10 +278,109 @@
|
|||||||
if (logName) meta.textContent = logName;
|
if (logName) meta.textContent = logName;
|
||||||
return r.text();
|
return r.text();
|
||||||
})
|
})
|
||||||
.then((text) => { content.textContent = text; })
|
.then((text) => {
|
||||||
|
if (withLineNumbers) renderRawLogLines(content, text);
|
||||||
|
else content.textContent = text;
|
||||||
|
})
|
||||||
.catch(() => { content.textContent = "Fehler beim Laden des Logs."; });
|
.catch(() => { content.textContent = "Fehler beim Laden des Logs."; });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ---------------- Hinweis-Icons (Hover-Tooltip + Klick-Modal) ---------------- */
|
||||||
|
/* Zentraler Mechanismus für ALLE "i"-Icons (siehe _hint_icon.html) app-weit
|
||||||
|
-- ein einziges delegiertes Set von Listenern statt pro Icon eigener
|
||||||
|
Handler, damit auch nachträglich per JS eingefügte Icons (z.B. in
|
||||||
|
dynamisch nachgeladenen Tabellenzeilen) ohne weiteres Zutun funktionieren.
|
||||||
|
Hover zeigt den Text als Tooltip neben dem Mauszeiger -- aber NUR auf
|
||||||
|
Geräten, die tatsächlich sinnvoll hovern können (matchMedia-Check bei
|
||||||
|
JEDEM mouseenter neu ausgewertet, nicht einmalig beim Laden gecacht,
|
||||||
|
damit z.B. ein Convertible/Tablet beim Umschalten Maus/Touch korrekt
|
||||||
|
reagiert). Auf reinen Touch-Geräten bleibt so nur Tap -> Modal übrig,
|
||||||
|
da dort ohnehin kein echtes mouseenter/mousemove-Hover stattfindet. */
|
||||||
|
function ensureHintTooltip() {
|
||||||
|
if (document.getElementById("hint-tooltip")) return;
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.id = "hint-tooltip";
|
||||||
|
document.body.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureHintModal() {
|
||||||
|
if (document.getElementById("hint-modal")) return;
|
||||||
|
const html = `
|
||||||
|
<div class="modal-overlay" id="hint-modal">
|
||||||
|
<div class="modal" style="max-width:440px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="hint-modal-title">Hinweis</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p id="hint-modal-body" class="text-dim" style="margin:0; line-height:1.6;"></p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Schließen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.insertAdjacentHTML("beforeend", html);
|
||||||
|
document.querySelectorAll("#hint-modal [data-close-modal]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => closeModal(btn));
|
||||||
|
});
|
||||||
|
document.getElementById("hint-modal").addEventListener("click", (e) => {
|
||||||
|
if (e.target.id === "hint-modal") closeModal(e.target);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function canHover() {
|
||||||
|
return window.matchMedia && window.matchMedia("(hover: hover) and (pointer: fine)").matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initHintIcons() {
|
||||||
|
if (!document.querySelector(".hint-icon")) return;
|
||||||
|
ensureHintTooltip();
|
||||||
|
const tooltip = document.getElementById("hint-tooltip");
|
||||||
|
|
||||||
|
function positionTooltip(e) {
|
||||||
|
const offset = 14;
|
||||||
|
const rect = tooltip.getBoundingClientRect();
|
||||||
|
let x = e.clientX + offset;
|
||||||
|
let y = e.clientY + offset;
|
||||||
|
if (x + rect.width > window.innerWidth - 8) x = e.clientX - rect.width - offset;
|
||||||
|
if (y + rect.height > window.innerHeight - 8) y = e.clientY - rect.height - offset;
|
||||||
|
tooltip.style.left = Math.max(8, x) + "px";
|
||||||
|
tooltip.style.top = Math.max(8, y) + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
// mouseenter/mouseleave bubbeln nicht -- Delegation via Capture-Phase
|
||||||
|
// auf document funktioniert dafür trotzdem zuverlässig.
|
||||||
|
document.addEventListener("mouseenter", function (e) {
|
||||||
|
const icon = e.target.closest && e.target.closest(".hint-icon");
|
||||||
|
if (!icon || !canHover()) return;
|
||||||
|
tooltip.textContent = icon.dataset.hint || "";
|
||||||
|
tooltip.style.display = "block";
|
||||||
|
positionTooltip(e);
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
document.addEventListener("mouseleave", function (e) {
|
||||||
|
const icon = e.target.closest && e.target.closest(".hint-icon");
|
||||||
|
if (!icon) return;
|
||||||
|
tooltip.style.display = "none";
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", function (e) {
|
||||||
|
if (tooltip.style.display === "block") positionTooltip(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", function (e) {
|
||||||
|
const icon = e.target.closest && e.target.closest(".hint-icon");
|
||||||
|
if (!icon) return;
|
||||||
|
e.preventDefault();
|
||||||
|
tooltip.style.display = "none";
|
||||||
|
ensureHintModal();
|
||||||
|
document.getElementById("hint-modal-title").textContent = icon.dataset.hintTitle || "Hinweis";
|
||||||
|
document.getElementById("hint-modal-body").textContent = icon.dataset.hint || "";
|
||||||
|
openModal("hint-modal");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- Warnung bei ungespeicherten Änderungen ---------------- */
|
/* ---------------- Warnung bei ungespeicherten Änderungen ---------------- */
|
||||||
/* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten
|
/* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten
|
||||||
Eingabefeldern (nicht nur versteckten Aktions-Feldern wie bei Löschen/
|
Eingabefeldern (nicht nur versteckten Aktions-Feldern wie bei Löschen/
|
||||||
@@ -607,6 +724,7 @@
|
|||||||
initLeaseCountdowns();
|
initLeaseCountdowns();
|
||||||
initUnsavedChangesGuard();
|
initUnsavedChangesGuard();
|
||||||
initSortableTables();
|
initSortableTables();
|
||||||
|
initHintIcons();
|
||||||
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme));
|
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+22
File diff suppressed because one or more lines are too long
@@ -0,0 +1,67 @@
|
|||||||
|
{#
|
||||||
|
Gemeinsame Auditlog-Zeilendarstellung -- von activity_log.html (Erstladung)
|
||||||
|
UND _audit_log_rows.html (AJAX-Nachladen über "Weitere 500 laden")
|
||||||
|
importiert, damit beide garantiert dieselbe Darstellung erzeugen und
|
||||||
|
action_labels/action_icons/category_of nicht an zwei Stellen gepflegt
|
||||||
|
werden müssen.
|
||||||
|
#}
|
||||||
|
{% set action_labels = {
|
||||||
|
"settings.update": "Einstellung geändert",
|
||||||
|
"device.create": "Gerät angelegt",
|
||||||
|
"device.edit": "Gerät bearbeitet",
|
||||||
|
"device.delete": "Gerät gelöscht",
|
||||||
|
"device.activate": "Gerät aktiviert",
|
||||||
|
"device.deactivate": "Gerät deaktiviert",
|
||||||
|
"switch.create": "Switch angelegt",
|
||||||
|
"switch.edit": "Switch bearbeitet",
|
||||||
|
"switch.delete": "Switch gelöscht",
|
||||||
|
"credential.create": "Zugangsdaten angelegt",
|
||||||
|
"credential.edit": "Zugangsdaten bearbeitet",
|
||||||
|
"credential.delete": "Zugangsdaten gelöscht",
|
||||||
|
"user.create": "Benutzer angelegt",
|
||||||
|
"user.edit": "Benutzer bearbeitet",
|
||||||
|
"user.delete": "Benutzer gelöscht",
|
||||||
|
"user.assign_group": "Gruppe zugewiesen",
|
||||||
|
"group.create": "Gruppe angelegt",
|
||||||
|
"group.edit": "Gruppe bearbeitet",
|
||||||
|
"group.delete": "Gruppe gelöscht",
|
||||||
|
"group.assign_admins": "Admin-Zuweisung geändert",
|
||||||
|
"profile.update": "Profil aktualisiert",
|
||||||
|
"profile.password": "Passwort geändert",
|
||||||
|
"check.run_now": "Prüfung manuell gestartet",
|
||||||
|
"fileshare.upload": "Datei(en) hochgeladen",
|
||||||
|
"fileshare.mkdir": "Ordner angelegt",
|
||||||
|
"fileshare.delete": "Datei/Ordner gelöscht",
|
||||||
|
"fileshare.rename": "Datei/Ordner umbenannt",
|
||||||
|
"fileshare.download": "Als ZIP heruntergeladen",
|
||||||
|
"auditlog.archive": "Auditlog archiviert",
|
||||||
|
"auditlog.export_delete": "Auditlog-Archiv exportiert",
|
||||||
|
} %}
|
||||||
|
{% set action_icons = {
|
||||||
|
"delete": '<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/>',
|
||||||
|
"create": '<path d="M12 5v14M5 12h14"/>',
|
||||||
|
"edit": '<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/>',
|
||||||
|
"activate": '<path d="M20 6L9 17l-5-5"/>',
|
||||||
|
"deactivate": '<circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6M9 9l6 6"/>',
|
||||||
|
} %}
|
||||||
|
{% set create_kinds = ("create", "upload", "mkdir") %}
|
||||||
|
|
||||||
|
{% macro category_of(kind) %}{% if kind in create_kinds %}create{% elif kind == "delete" %}delete{% else %}edit{% endif %}{% endmacro %}
|
||||||
|
|
||||||
|
{% macro audit_row(e) %}
|
||||||
|
{% set kind = e['action'].split('.')[-1] %}
|
||||||
|
{% set cat = category_of(kind)|trim %}
|
||||||
|
<tr data-category="{{ cat }}" data-sort-ts="{{ e['ts'] }}" data-sort-user="{{ e['username']|lower }}"
|
||||||
|
data-sort-action="{{ action_labels.get(e['action'], e['action'])|lower }}" data-sort-target="{{ (e['target'] or '')|lower }}">
|
||||||
|
<td class="text-dim mono" style="font-size:12.5px;">{{ e['ts'] }}</td>
|
||||||
|
<td class="cell-name">{{ e['username'] }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="pill action-pill action-pill--{{ cat }}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ (action_icons.get(kind) or action_icons['edit'])|safe }}</svg>
|
||||||
|
{{ action_labels.get(e['action'], e['action']) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ e['target'] or '—' }}</td>
|
||||||
|
<td class="text-dim">{{ e['details'] or '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endmacro %}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{# Reines Zeilenfragment fürs AJAX-Nachladen (activity_log_more()) -- kein
|
||||||
|
umschließendes <table>/<tbody>, wird per insertAdjacentHTML direkt an das
|
||||||
|
bestehende tbody von #auditTable angehängt. #}
|
||||||
|
{% import "_audit_log_macros.html" as m %}
|
||||||
|
{% for e in entries %}{{ m.audit_row(e) }}{% endfor %}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{#
|
||||||
|
Gemeinsames "i"-Hinweis-Icon: Hover zeigt den Text als Tooltip neben dem
|
||||||
|
Mauszeiger, Klick/Tap öffnet dasselbe (wiederverwendete) Modal mit dem
|
||||||
|
vollen Text -- Mechanik lebt zentral in app.js (initHintIcons), hier wird
|
||||||
|
nur das Icon mit seinen Daten-Attributen erzeugt. title = Kontext für die
|
||||||
|
Modal-Überschrift (z.B. das zugehörige Feld-Label), text = der eigentliche
|
||||||
|
Erklärungstext.
|
||||||
|
#}
|
||||||
|
{% macro hint_icon(text, title='Hinweis') %}<button type="button" class="hint-icon" data-hint="{{ text }}" data-hint-title="{{ title }}" aria-label="Hinweis: {{ title }}">i</button>{% endmacro %}
|
||||||
@@ -1,75 +1,224 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% set active_page = "logs" %}
|
{% set active_page = "logs" %}
|
||||||
{% block page_title %}Änderungen{% endblock %}
|
{% block page_title %}Auditlog{% endblock %}
|
||||||
{% block page_sub %}<div class="topbar-sub">{{ entries|length }} Einträge (letzte 500)</div>{% endblock %}
|
{% block page_sub %}
|
||||||
|
<div class="topbar-sub" id="auditEntryCount">
|
||||||
|
{% if has_more %}{{ entries|length }} von {{ total_count }} Einträgen geladen{% else %}{{ entries|length }} Eintrag{{ 'e' if entries|length != 1 else '' }}{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_audit_log_macros.html" as m %}
|
||||||
|
|
||||||
{% set action_labels = {
|
{% set ns = namespace(create=0, delete=0, edit=0) %}
|
||||||
"settings.update": "Einstellung geändert",
|
{% for e in entries %}
|
||||||
"device.create": "Gerät angelegt",
|
{% set cat = m.category_of(e['action'].split('.')[-1])|trim %}
|
||||||
"device.edit": "Gerät bearbeitet",
|
{% if cat == "create" %}{% set ns.create = ns.create + 1 %}
|
||||||
"device.delete": "Gerät gelöscht",
|
{% elif cat == "delete" %}{% set ns.delete = ns.delete + 1 %}
|
||||||
"device.activate": "Gerät aktiviert",
|
{% else %}{% set ns.edit = ns.edit + 1 %}{% endif %}
|
||||||
"device.deactivate": "Gerät deaktiviert",
|
{% endfor %}
|
||||||
"switch.create": "Switch angelegt",
|
|
||||||
"switch.edit": "Switch bearbeitet",
|
{% macro fmt_count(loaded, total) %}{% if loaded < total %}{{ loaded }} / {{ total }}{% else %}{{ loaded }}{% endif %}{% endmacro %}
|
||||||
"switch.delete": "Switch gelöscht",
|
|
||||||
"credential.create": "Zugangsdaten angelegt",
|
<div class="stat-row" style="margin-bottom:18px;">
|
||||||
"credential.edit": "Zugangsdaten bearbeitet",
|
<div class="stat-card" data-category-filter="">
|
||||||
"credential.delete": "Zugangsdaten gelöscht",
|
<div class="stat-label">Alle</div>
|
||||||
"user.create": "Benutzer angelegt",
|
<div class="stat-value" id="statAll">{{ fmt_count(entries|length, total_count) }}</div>
|
||||||
"user.edit": "Benutzer bearbeitet",
|
</div>
|
||||||
"user.delete": "Benutzer gelöscht",
|
<div class="stat-card" data-category-filter="create">
|
||||||
"user.assign_group": "Gruppe zugewiesen",
|
<div class="stat-label">Hinzufügen</div>
|
||||||
"group.create": "Gruppe angelegt",
|
<div class="stat-value" id="statCreate" style="color:var(--success);">{{ fmt_count(ns.create, total_create) }}</div>
|
||||||
"group.edit": "Gruppe bearbeitet",
|
</div>
|
||||||
"group.delete": "Gruppe gelöscht",
|
<div class="stat-card" data-category-filter="edit">
|
||||||
"group.assign_admins": "Admin-Zuweisung geändert",
|
<div class="stat-label">Änderungen</div>
|
||||||
"profile.update": "Profil aktualisiert",
|
<div class="stat-value" id="statEdit" style="color:var(--accent-strong);">{{ fmt_count(ns.edit, total_edit) }}</div>
|
||||||
"profile.password": "Passwort geändert",
|
</div>
|
||||||
"check.run_now": "Prüfung manuell gestartet",
|
<div class="stat-card" data-category-filter="delete">
|
||||||
} %}
|
<div class="stat-label">Löschungen</div>
|
||||||
{% set action_icons = {
|
<div class="stat-value" id="statDelete" style="color:var(--danger);">{{ fmt_count(ns.delete, total_delete) }}</div>
|
||||||
"delete": '<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/>',
|
</div>
|
||||||
"create": '<path d="M12 5v14M5 12h14"/>',
|
</div>
|
||||||
"edit": '<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/>',
|
|
||||||
"activate": '<path d="M20 6L9 17l-5-5"/>',
|
|
||||||
"deactivate": '<circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6M9 9l6 6"/>',
|
|
||||||
} %}
|
|
||||||
|
|
||||||
<div class="table-wrap">
|
<div 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" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||||
|
<input type="text" id="auditSearch" placeholder="Benutzer, Aktion, Ziel oder Details durchsuchen...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div style="overflow-x:auto;">
|
<div style="overflow-x:auto;">
|
||||||
<table class="data-table">
|
<table class="data-table" id="auditTable" data-sortable>
|
||||||
<thead><tr><th style="width:1%;"></th><th>Zeitpunkt</th><th>Benutzer</th><th>Aktion</th><th>Ziel</th><th>Details</th></tr></thead>
|
<thead><tr>
|
||||||
|
<th data-sort-key="ts" style="width:1%; white-space:nowrap;">Zeitpunkt</th>
|
||||||
|
<th data-sort-key="user" style="width:1%; white-space:nowrap;">Benutzer</th>
|
||||||
|
<th data-sort-key="action" style="width:1%; white-space:nowrap;">Aktion</th>
|
||||||
|
<th data-sort-key="target" style="width:1%; white-space:nowrap;">Ziel</th>
|
||||||
|
<th>Details</th>
|
||||||
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for e in entries %}
|
{% for e in entries %}{{ m.audit_row(e) }}{% else %}
|
||||||
{% set kind = e['action'].split('.')[-1] %}
|
<tr class="empty-row"><td colspan="5">Noch keine Änderungen protokolliert.</td></tr>
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
{% if avatars.get(e['username']) %}
|
|
||||||
<img class="avatar-sm" src="{{ url_for('static', filename='uploads/avatars/' + avatars[e['username']]) }}" alt="">
|
|
||||||
{% else %}
|
|
||||||
<span class="avatar-sm avatar-placeholder">{{ e['username'][:1]|upper }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="text-dim mono" style="font-size:12.5px;">{{ e['ts'] }}</td>
|
|
||||||
<td class="cell-name">{{ e['username'] }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="pill action-pill">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ (action_icons.get(kind) or action_icons['edit'])|safe }}</svg>
|
|
||||||
{{ action_labels.get(e['action'], e['action']) }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>{{ e['target'] or '—' }}</td>
|
|
||||||
<td class="text-dim">{{ e['details'] or '—' }}</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
<tr class="empty-row"><td colspan="6">Noch keine Änderungen protokolliert.</td></tr>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p id="auditNoResults" class="text-faint hidden" style="padding:16px; text-align:center; font-size:12.5px;">Keine Einträge für diese Auswahl.</p>
|
||||||
|
<div id="auditLoadMoreWrap" class="{{ 'hidden' if not has_more }}" style="padding:16px; text-align:center; display:flex; gap:8px; justify-content:center;">
|
||||||
|
<button type="button" id="auditLoadMoreBtn" class="btn btn-primary" data-oldest-id="{{ oldest_loaded_id or '' }}">
|
||||||
|
Mehr laden (300)
|
||||||
|
</button>
|
||||||
|
<button type="button" id="auditLoadAllBtn" class="btn btn-secondary">
|
||||||
|
Alle laden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
const searchInput = document.getElementById("auditSearch");
|
||||||
|
const noResults = document.getElementById("auditNoResults");
|
||||||
|
let activeCategory = null;
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
const q = (searchInput ? searchInput.value : "").trim().toLowerCase();
|
||||||
|
const rows = Array.from(document.querySelectorAll("#auditTable tbody tr"));
|
||||||
|
let anyVisible = false;
|
||||||
|
rows.forEach(function (row) {
|
||||||
|
if (row.classList.contains("empty-row")) return;
|
||||||
|
const categoryMatches = !activeCategory || row.dataset.category === activeCategory;
|
||||||
|
const textMatches = !q || row.innerText.toLowerCase().includes(q);
|
||||||
|
const match = categoryMatches && textMatches;
|
||||||
|
row.style.display = match ? "" : "none";
|
||||||
|
if (match) anyVisible = true;
|
||||||
|
});
|
||||||
|
if (noResults) noResults.classList.toggle("hidden", anyVisible);
|
||||||
|
document.querySelectorAll(".stat-card[data-category-filter]").forEach(function (card) {
|
||||||
|
const isTotal = card.dataset.categoryFilter === "";
|
||||||
|
const active = activeCategory === null ? isTotal : card.dataset.categoryFilter === activeCategory;
|
||||||
|
card.classList.toggle("active", active);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchInput) searchInput.addEventListener("input", applyFilters);
|
||||||
|
|
||||||
|
document.querySelectorAll(".stat-card[data-category-filter]").forEach(function (card) {
|
||||||
|
card.addEventListener("click", function () {
|
||||||
|
const key = this.dataset.categoryFilter;
|
||||||
|
activeCategory = (!key || activeCategory === key) ? null : key;
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---- Nachladen älterer Einträge (Mehr laden / Alle laden) ---- */
|
||||||
|
const totalCount = {{ total_count }};
|
||||||
|
const totalCreate = {{ total_create }};
|
||||||
|
const totalEdit = {{ total_edit }};
|
||||||
|
const totalDelete = {{ total_delete }};
|
||||||
|
const entryCountLabel = document.getElementById("auditEntryCount");
|
||||||
|
|
||||||
|
function fmtCount(loaded, total) {
|
||||||
|
return loaded < total ? loaded + " / " + total : String(loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recomputeStats() {
|
||||||
|
const rows = Array.from(document.querySelectorAll("#auditTable tbody tr[data-category]"));
|
||||||
|
const counts = { create: 0, edit: 0, delete: 0 };
|
||||||
|
rows.forEach(function (r) { counts[r.dataset.category] = (counts[r.dataset.category] || 0) + 1; });
|
||||||
|
document.getElementById("statAll").textContent = fmtCount(rows.length, totalCount);
|
||||||
|
document.getElementById("statCreate").textContent = fmtCount(counts.create, totalCreate);
|
||||||
|
document.getElementById("statEdit").textContent = fmtCount(counts.edit, totalEdit);
|
||||||
|
document.getElementById("statDelete").textContent = fmtCount(counts.delete, totalDelete);
|
||||||
|
if (entryCountLabel) {
|
||||||
|
entryCountLabel.textContent = rows.length < totalCount
|
||||||
|
? rows.length + " von " + totalCount + " Einträgen geladen"
|
||||||
|
: rows.length + " Eintrag" + (rows.length === 1 ? "" : "e");
|
||||||
|
}
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMoreBtn = document.getElementById("auditLoadMoreBtn");
|
||||||
|
const loadAllBtn = document.getElementById("auditLoadAllBtn");
|
||||||
|
const loadMoreWrap = document.getElementById("auditLoadMoreWrap");
|
||||||
|
const tbody = document.querySelector("#auditTable tbody");
|
||||||
|
const MORE_URL = "{{ url_for('activity_log_more') }}";
|
||||||
|
|
||||||
|
// Laedt genau einen weiteren 300er-Block nach und haengt ihn an -- von
|
||||||
|
// beiden Buttons genutzt: "Mehr laden" ruft das einmal auf, "Alle laden"
|
||||||
|
// ruft es wiederholt auf, bis der Server "keine weiteren mehr" meldet.
|
||||||
|
function loadMoreOnce() {
|
||||||
|
const oldestId = loadMoreBtn.dataset.oldestId;
|
||||||
|
if (!oldestId) return Promise.resolve({ hasMore: false });
|
||||||
|
return fetch(MORE_URL + "?before_id=" + encodeURIComponent(oldestId))
|
||||||
|
.then(function (r) {
|
||||||
|
const hasMore = r.headers.get("X-Has-More") === "1";
|
||||||
|
const newOldestId = r.headers.get("X-Oldest-Id");
|
||||||
|
return r.text().then(function (html) {
|
||||||
|
return { html: html, hasMore: hasMore, newOldestId: newOldestId };
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function (result) {
|
||||||
|
const emptyRow = tbody.querySelector(".empty-row");
|
||||||
|
if (emptyRow) emptyRow.remove();
|
||||||
|
tbody.insertAdjacentHTML("beforeend", result.html);
|
||||||
|
if (result.newOldestId) loadMoreBtn.dataset.oldestId = result.newOldestId;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadMoreBtn) {
|
||||||
|
loadMoreBtn.addEventListener("click", function () {
|
||||||
|
loadMoreBtn.disabled = true;
|
||||||
|
if (loadAllBtn) loadAllBtn.disabled = true;
|
||||||
|
const originalText = loadMoreBtn.textContent;
|
||||||
|
loadMoreBtn.textContent = "Lädt …";
|
||||||
|
loadMoreOnce()
|
||||||
|
.then(function (result) {
|
||||||
|
if (!result.hasMore) loadMoreWrap.classList.add("hidden");
|
||||||
|
recomputeStats();
|
||||||
|
applyFilters();
|
||||||
|
})
|
||||||
|
.catch(function () { /* Button unten wird trotzdem wieder aktiviert */ })
|
||||||
|
.then(function () {
|
||||||
|
loadMoreBtn.disabled = false;
|
||||||
|
if (loadAllBtn) loadAllBtn.disabled = false;
|
||||||
|
loadMoreBtn.textContent = originalText;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadAllBtn) {
|
||||||
|
loadAllBtn.addEventListener("click", function () {
|
||||||
|
loadMoreBtn.disabled = true;
|
||||||
|
loadAllBtn.disabled = true;
|
||||||
|
const originalText = loadAllBtn.textContent;
|
||||||
|
|
||||||
|
function step() {
|
||||||
|
loadAllBtn.textContent = "Lädt … (" + recomputeStats() + " von " + totalCount + ")";
|
||||||
|
return loadMoreOnce().then(function (result) {
|
||||||
|
return result.hasMore ? step() : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
step()
|
||||||
|
.then(function () {
|
||||||
|
loadMoreWrap.classList.add("hidden");
|
||||||
|
})
|
||||||
|
.catch(function () { /* teilweise geladene Eintraege bleiben stehen */ })
|
||||||
|
.then(function () {
|
||||||
|
recomputeStats();
|
||||||
|
applyFilters();
|
||||||
|
loadMoreBtn.disabled = false;
|
||||||
|
loadAllBtn.disabled = false;
|
||||||
|
loadAllBtn.textContent = originalText;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFilters();
|
||||||
|
recomputeStats();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">{{ credentials|length }} Zugangsdaten</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">{{ credentials|length }} Zugangsdaten</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
@@ -95,13 +96,12 @@
|
|||||||
<div class="field"><label>Username</label>
|
<div class="field"><label>Username</label>
|
||||||
<input type="text" name="username" required placeholder="z.B. admin">
|
<input type="text" name="username" required placeholder="z.B. admin">
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Kategorie</label>
|
<div class="field"><label>Kategorie {{ hi.hint_icon("Bestimmt u.a., ob diese Zugangsdaten unter „Wartung“ für Bulk-Updates nutzbar sind.", "Kategorie") }}</label>
|
||||||
<select name="category">
|
<select name="category">
|
||||||
{% for key, label in categories %}
|
{% for key, label in categories %}
|
||||||
<option value="{{ key }}">{{ label }}</option>
|
<option value="{{ key }}">{{ label }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<div class="hint">Bestimmt u.a., ob diese Zugangsdaten unter „Wartung“ für Bulk-Updates nutzbar sind.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Passwort</label>
|
<div class="field"><label>Passwort</label>
|
||||||
<input type="password" id="password_add" name="password" required>
|
<input type="password" id="password_add" name="password" required>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
@@ -125,9 +126,8 @@
|
|||||||
<input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF">
|
<input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF">
|
||||||
<div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div>
|
<div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Switchport</label>
|
<div class="field"><label>Switchport {{ hi.hint_icon("Physische Port-Nummer AM SWITCH (z.B. Port 3 von 48), an dem das Gerät eingesteckt ist — für den PoE-Neustart. Kein Netzwerk-/TCP-Port, keine SSH-Anmeldung.", "Switchport") }}</label>
|
||||||
<input type="text" name="port" placeholder="z.B. 3">
|
<input type="text" name="port" placeholder="z.B. 3">
|
||||||
<div class="field-hint">Physische Port-Nummer AM SWITCH (z.B. Port 3 von 48), an dem das Gerät eingesteckt ist — für den PoE-Neustart. Kein Netzwerk-/TCP-Port, keine SSH-Anmeldung.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Switch (optional)</label>
|
<div class="field"><label>Switch (optional)</label>
|
||||||
<select name="switch_hostname">
|
<select name="switch_hostname">
|
||||||
@@ -136,9 +136,8 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="permission-group-title">SSH-Zugriff (optional, z.B. für Update-Aktionen unter „Wartung“)</div>
|
<div class="permission-group-title">SSH-Zugriff (optional, z.B. für Update-Aktionen unter „Wartung“)</div>
|
||||||
<div class="field"><label>SSH-Port</label>
|
<div class="field"><label>SSH-Port {{ hi.hint_icon("TCP-Netzwerk-Port für die SSH-Anmeldung an der IP-Adresse dieses Geräts (siehe Feld „IP-Adresse“ oben). Leer lassen, wenn Standard-Port 22 verwendet wird.", "SSH-Port") }}</label>
|
||||||
<input type="number" name="ssh_port" min="1" max="65535" placeholder="22 (Standard)">
|
<input type="number" name="ssh_port" min="1" max="65535" placeholder="22 (Standard)">
|
||||||
<div class="field-hint">TCP-Netzwerk-Port für die SSH-Anmeldung an der IP-Adresse dieses Geräts (siehe Feld „IP-Adresse“ oben). Leer lassen, wenn Standard-Port 22 verwendet wird.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Zugangsdaten</label>
|
<label>Zugangsdaten</label>
|
||||||
@@ -201,14 +200,12 @@
|
|||||||
<input type="text" name="mac" id="edit_mac" required>
|
<input type="text" name="mac" id="edit_mac" required>
|
||||||
<div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div>
|
<div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Switchport</label>
|
<div class="field"><label>Switchport {{ hi.hint_icon("Portnummer am zugeordneten Switch, für den PoE-Neustart. Hat nichts mit dem SSH-Port unten zu tun.", "Switchport") }}</label>
|
||||||
<input type="text" name="port" id="edit_port">
|
<input type="text" name="port" id="edit_port">
|
||||||
<div class="field-hint">Portnummer am zugeordneten Switch, für den PoE-Neustart. Hat nichts mit dem SSH-Port unten zu tun.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="permission-group-title">SSH-Zugriff (optional, z.B. für Update-Aktionen unter „Wartung“)</div>
|
<div class="permission-group-title">SSH-Zugriff (optional, z.B. für Update-Aktionen unter „Wartung“)</div>
|
||||||
<div class="field"><label>SSH-Port</label>
|
<div class="field"><label>SSH-Port {{ hi.hint_icon("TCP-Netzwerk-Port für die SSH-Anmeldung an der IP-Adresse dieses Geräts (siehe Feld „IP-Adresse“ oben). Leer lassen, wenn Standard-Port 22 verwendet wird.", "SSH-Port") }}</label>
|
||||||
<input type="number" name="ssh_port" id="edit_ssh_port" min="1" max="65535" placeholder="22 (Standard)">
|
<input type="number" name="ssh_port" id="edit_ssh_port" min="1" max="65535" placeholder="22 (Standard)">
|
||||||
<div class="field-hint">TCP-Netzwerk-Port für die SSH-Anmeldung an der IP-Adresse dieses Geräts (siehe Feld „IP-Adresse“ oben). Leer lassen, wenn Standard-Port 22 verwendet wird.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Zugangsdaten</label>
|
<label>Zugangsdaten</label>
|
||||||
|
|||||||
@@ -4,33 +4,50 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">{{ selected_share }}{% if rel_path %} / {{ rel_path }}{% endif %}</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">{{ selected_share }}{% if rel_path %} / {{ rel_path }}{% endif %}</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
|
||||||
|
{% macro render_tree_node(share, path, name, expanded_map, active_path) %}
|
||||||
|
<li class="tree-node" data-share="{{ share }}" data-path="{{ path }}">
|
||||||
|
<div class="tree-row{{ ' active' if path == active_path else '' }}">
|
||||||
|
{% if path in expanded_map %}
|
||||||
|
<button type="button" class="tree-toggle" aria-expanded="true">▼</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="button" class="tree-toggle" aria-expanded="false">▶</button>
|
||||||
|
{% endif %}
|
||||||
|
<span class="tree-label">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px; height:14px; margin-right:5px; vertical-align:-2px; color:var(--accent);"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
||||||
|
{{ name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul class="tree-children{{ '' if path in expanded_map else ' hidden' }}"{% if path in expanded_map %} data-loaded="1"{% endif %}>
|
||||||
|
{% if path in expanded_map %}
|
||||||
|
{% for child in expanded_map[path] %}
|
||||||
|
{{ render_tree_node(share, (path ~ '/' ~ child) if path else child, child, expanded_map, active_path) }}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="font-size:16px;">Dateifreigaben</h2>
|
<h2 style="font-size:16px;">Dateifreigaben</h2>
|
||||||
<div class="hint">Freigaben je nach AD-Gruppenmitgliedschaft für diese Sitzung gemountet — wird beim Abmelden wieder ausgehängt.</div>
|
<div class="hint">Freigaben je nach AD-Gruppenmitgliedschaft für diese Sitzung gemountet — wird beim Abmelden wieder ausgehängt.</div>
|
||||||
</div>
|
</div>
|
||||||
{% if shares|length > 1 %}
|
</div>
|
||||||
<div class="flex gap-2" style="align-items:center;">
|
|
||||||
<label for="shareSelect" class="text-faint" style="font-size:12.5px; font-weight:600;">Freigabe:</label>
|
<div class="fileshare-layout">
|
||||||
<select id="shareSelect" onchange="window.location.href='{{ url_for('fileshare') }}?share=' + encodeURIComponent(this.value);">
|
<div class="fileshare-tree card">
|
||||||
|
<div class="fileshare-tree-title">Freigaben</div>
|
||||||
|
<ul class="tree-root" id="fileshareTree">
|
||||||
{% for s in shares %}
|
{% for s in shares %}
|
||||||
<option value="{{ s }}" {% if s == selected_share %}selected{% endif %}>{{ s }}</option>
|
{{ render_tree_node(s, '', s, tree_expanded if s == selected_share else {}, rel_path if s == selected_share else None) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2" style="align-items:center; margin-bottom:14px; flex-wrap:wrap;">
|
<div class="fileshare-main">
|
||||||
<a href="{{ url_for('fileshare', share=selected_share) }}" class="mono" style="font-size:13px; font-weight:600;">{{ selected_share }}</a>
|
<div class="table-wrap">
|
||||||
{% for b in breadcrumbs %}
|
|
||||||
<span class="text-faint">/</span>
|
|
||||||
<a href="{{ url_for('fileshare', share=selected_share, path=b.path) }}" class="mono" style="font-size:13px;">{{ b.name }}</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-wrap" style="margin-bottom:16px;">
|
|
||||||
<div class="table-toolbar">
|
<div class="table-toolbar">
|
||||||
<div class="search-input">
|
<div class="search-input">
|
||||||
<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>
|
<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>
|
||||||
@@ -50,10 +67,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="bulkActionsBar" class="flex gap-2 hidden" data-share="{{ selected_share }}" data-path="{{ rel_path }}" style="align-items:center; margin-bottom:10px; flex-wrap:wrap;">
|
||||||
|
<span id="bulkSelectedCount" class="text-faint" style="font-size:12.5px; font-weight:600;"></span>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" onclick="bulkDownload()">
|
||||||
|
<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>
|
||||||
|
Herunterladen (ZIP)
|
||||||
|
</button>
|
||||||
|
{% if can_edit %}
|
||||||
|
<button type="button" class="btn btn-sm" style="color:var(--danger); background:transparent; border-color:var(--danger-dim);" onclick="bulkDelete()">
|
||||||
|
<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>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if entries %}
|
{% if entries %}
|
||||||
<div style="overflow-x:auto;">
|
<div style="overflow-x:auto;">
|
||||||
<table class="data-table" id="fileTable" data-sortable>
|
<table class="data-table" id="fileTable" data-sortable>
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
|
<th style="width:1%;"><input type="checkbox" id="selectAllFiles"></th>
|
||||||
<th data-sort-key="name">Name</th>
|
<th data-sort-key="name">Name</th>
|
||||||
<th data-sort-key="size">Größe</th>
|
<th data-sort-key="size">Größe</th>
|
||||||
<th data-sort-key="mtime">Geändert</th>
|
<th data-sort-key="mtime">Geändert</th>
|
||||||
@@ -62,6 +94,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for e in entries %}
|
{% for e in entries %}
|
||||||
<tr data-sort-name="{{ e.name|lower }}" data-sort-size="{{ 0 if e.is_dir else e.size_str }}" data-sort-mtime="{{ e.mtime_str }}">
|
<tr data-sort-name="{{ e.name|lower }}" data-sort-size="{{ 0 if e.is_dir else e.size_str }}" data-sort-mtime="{{ e.mtime_str }}">
|
||||||
|
<td><input type="checkbox" class="row-select" value="{{ e.name }}"></td>
|
||||||
<td class="cell-name">
|
<td class="cell-name">
|
||||||
{% if e.is_dir %}
|
{% if e.is_dir %}
|
||||||
<a href="{{ url_for('fileshare', share=selected_share, path=(rel_path ~ '/' ~ e.name) if rel_path else e.name) }}">
|
<a href="{{ url_for('fileshare', share=selected_share, path=(rel_path ~ '/' ~ e.name) if rel_path else e.name) }}">
|
||||||
@@ -77,6 +110,14 @@
|
|||||||
<td class="text-faint">{{ e.mtime_str }}</td>
|
<td class="text-faint">{{ e.mtime_str }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
|
{% if e.preview_kind %}
|
||||||
|
<button type="button" class="icon-btn" title="Vorschau"
|
||||||
|
data-preview-kind="{{ e.preview_kind }}"
|
||||||
|
data-preview-name="{{ e.name }}"
|
||||||
|
data-preview-url="{{ url_for('fileshare_view', share=selected_share, path=(rel_path ~ '/' ~ e.name) if rel_path else e.name) }}">
|
||||||
|
<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>
|
||||||
|
{% endif %}
|
||||||
{% if not e.is_dir %}
|
{% if not e.is_dir %}
|
||||||
<a class="icon-btn" title="Herunterladen" href="{{ url_for('fileshare_download', share=selected_share, path=(rel_path ~ '/' ~ e.name) if rel_path else e.name) }}">
|
<a class="icon-btn" title="Herunterladen" href="{{ url_for('fileshare_download', share=selected_share, path=(rel_path ~ '/' ~ e.name) if rel_path else e.name) }}">
|
||||||
<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>
|
<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>
|
||||||
@@ -105,6 +146,8 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div style="padding:40px 16px; text-align:center; color:var(--text-faint);">Dieser Ordner ist leer.</div>
|
<div style="padding:40px 16px; text-align:center; color:var(--text-faint);">Dieser Ordner ist leer.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if can_create %}
|
{% if can_create %}
|
||||||
@@ -114,13 +157,14 @@
|
|||||||
<input type="hidden" name="share" value="{{ selected_share }}">
|
<input type="hidden" name="share" value="{{ selected_share }}">
|
||||||
<input type="hidden" name="path" value="{{ rel_path }}">
|
<input type="hidden" name="path" value="{{ rel_path }}">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3>Datei hochladen</h3>
|
<h3>Datei(en) hochladen</h3>
|
||||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Datei</label>
|
<label>Datei(en) {{ hi.hint_icon("Insgesamt maximal 15 MB pro Upload-Vorgang. Mehrfachauswahl möglich (auch mehrmals nacheinander — bereits hinzugefügte Dateien bleiben dabei erhalten).", "Datei(en)") }}</label>
|
||||||
<input type="file" name="file" required>
|
<input type="file" name="file" id="uploadFileInput" multiple required>
|
||||||
|
<div id="uploadFileList" class="upload-file-list"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-hint">Wird in „{{ selected_share }}{% if rel_path %} / {{ rel_path }}{% endif %}“ hochgeladen. Eine bereits vorhandene Datei gleichen Namens wird überschrieben.</div>
|
<div class="field-hint">Wird in „{{ selected_share }}{% if rel_path %} / {{ rel_path }}{% endif %}“ hochgeladen. Eine bereits vorhandene Datei gleichen Namens wird überschrieben.</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,10 +226,27 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="previewModal">
|
||||||
|
<div class="modal" style="max-width:900px; width:90vw;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="previewTitle">Vorschau</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="previewBody" style="max-height:75vh; overflow:auto;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/vendor/mammoth.browser.min.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/vendor/xlsx.full.min.js') }}"></script>
|
||||||
<script>
|
<script>
|
||||||
|
const FILESHARE_SUBFOLDERS_URL = "{{ url_for('fileshare_subfolders') }}";
|
||||||
|
const FILESHARE_BASE_URL = "{{ url_for('fileshare') }}";
|
||||||
|
const FILESHARE_DOWNLOAD_MULTI_URL = "{{ url_for('fileshare_download_multi') }}";
|
||||||
|
const FILESHARE_DELETE_MULTI_URL = "{{ url_for('fileshare_delete_multi') }}";
|
||||||
|
|
||||||
function openRenameModal(name) {
|
function openRenameModal(name) {
|
||||||
document.getElementById("renameOldName").value = name;
|
document.getElementById("renameOldName").value = name;
|
||||||
document.getElementById("renameNewName").value = name;
|
document.getElementById("renameNewName").value = name;
|
||||||
@@ -199,5 +260,358 @@ function filterTable(inputId, tableId) {
|
|||||||
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
|
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- Mehrfachauswahl (Herunterladen als ZIP / Löschen) ---------------- */
|
||||||
|
|
||||||
|
function getSelectedFileNames() {
|
||||||
|
return Array.from(document.querySelectorAll("#fileTable .row-select:checked")).map(function (cb) { return cb.value; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBulkBar() {
|
||||||
|
const names = getSelectedFileNames();
|
||||||
|
const bar = document.getElementById("bulkActionsBar");
|
||||||
|
const countEl = document.getElementById("bulkSelectedCount");
|
||||||
|
if (!bar) return;
|
||||||
|
if (names.length > 0) {
|
||||||
|
bar.classList.remove("hidden");
|
||||||
|
countEl.textContent = names.length + " ausgewählt";
|
||||||
|
} else {
|
||||||
|
bar.classList.add("hidden");
|
||||||
|
}
|
||||||
|
const selectAll = document.getElementById("selectAllFiles");
|
||||||
|
const allBoxes = document.querySelectorAll("#fileTable .row-select");
|
||||||
|
if (selectAll && allBoxes.length) {
|
||||||
|
selectAll.checked = names.length === allBoxes.length;
|
||||||
|
selectAll.indeterminate = names.length > 0 && names.length < allBoxes.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitBulkForm(actionUrl, names) {
|
||||||
|
// data-share/data-path statt Jinja-Werte direkt in einen JS-String-
|
||||||
|
// Literal zu setzen -- Ordnernamen kommen von der echten Freigabe und
|
||||||
|
// koennten Anfuehrungszeichen o.ae. enthalten, ueber HTML-Attribute
|
||||||
|
// (von Jinja automatisch escaped) ist das unproblematisch.
|
||||||
|
const bar = document.getElementById("bulkActionsBar");
|
||||||
|
const form = document.createElement("form");
|
||||||
|
form.method = "post";
|
||||||
|
form.action = actionUrl;
|
||||||
|
form.style.display = "none";
|
||||||
|
[["share", bar.dataset.share], ["path", bar.dataset.path]].forEach(function (pair) {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "hidden";
|
||||||
|
input.name = pair[0];
|
||||||
|
input.value = pair[1];
|
||||||
|
form.appendChild(input);
|
||||||
|
});
|
||||||
|
names.forEach(function (name) {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "hidden";
|
||||||
|
input.name = "names";
|
||||||
|
input.value = name;
|
||||||
|
form.appendChild(input);
|
||||||
|
});
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bulkDownload() {
|
||||||
|
const names = getSelectedFileNames();
|
||||||
|
if (!names.length) return;
|
||||||
|
submitBulkForm(FILESHARE_DOWNLOAD_MULTI_URL, names);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bulkDelete() {
|
||||||
|
const names = getSelectedFileNames();
|
||||||
|
if (!names.length) return;
|
||||||
|
window.confirmAction(
|
||||||
|
names.length + " ausgewählte Elemente wirklich endgültig löschen?",
|
||||||
|
function () { submitBulkForm(FILESHARE_DELETE_MULTI_URL, names); },
|
||||||
|
"Auswahl löschen?"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
const selectAll = document.getElementById("selectAllFiles");
|
||||||
|
if (selectAll) {
|
||||||
|
selectAll.addEventListener("change", function () {
|
||||||
|
document.querySelectorAll("#fileTable .row-select").forEach(function (cb) { cb.checked = selectAll.checked; });
|
||||||
|
updateBulkBar();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.querySelectorAll("#fileTable .row-select").forEach(function (cb) {
|
||||||
|
cb.addEventListener("change", updateBulkBar);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------------- Mehrfach-Upload (Multiauswahl + mehrmals nacheinander) ---------------- */
|
||||||
|
/* Ein <input type=file multiple> ERSETZT bei jeder erneuten Dateiauswahl
|
||||||
|
die vorherige -- fuer "mehrmals nacheinander hinzufuegen" wird deshalb
|
||||||
|
selbst eine "angesammelte" Auswahl per DataTransfer gepflegt und nach
|
||||||
|
jeder Aenderung zurueck auf das Input-Feld geschrieben, sodass das
|
||||||
|
normale <form>-Submit (kein fetch() noetig) am Ende alle gesammelten
|
||||||
|
Dateien mitschickt. DataTransfer-Zuweisung an .files wird von allen
|
||||||
|
gaengigen Mobil-Browsern (Android Chrome, iOS Safari) mitgetragen; falls
|
||||||
|
nicht, faellt es einfach auf das native Verhalten (letzte Auswahl zaehlt)
|
||||||
|
zurueck, ohne den Upload an sich zu verhindern. */
|
||||||
|
(function () {
|
||||||
|
const input = document.getElementById("uploadFileInput");
|
||||||
|
const listEl = document.getElementById("uploadFileList");
|
||||||
|
if (!input || !listEl) return;
|
||||||
|
let staged = null;
|
||||||
|
try { staged = new DataTransfer(); } catch (e) { staged = null; }
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
if (!staged) return;
|
||||||
|
Array.from(staged.files).forEach(function (file, idx) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "upload-file-row";
|
||||||
|
const name = document.createElement("span");
|
||||||
|
name.textContent = file.name;
|
||||||
|
const removeBtn = document.createElement("button");
|
||||||
|
removeBtn.type = "button";
|
||||||
|
removeBtn.className = "upload-file-remove";
|
||||||
|
removeBtn.setAttribute("aria-label", "Entfernen");
|
||||||
|
removeBtn.textContent = "×";
|
||||||
|
removeBtn.addEventListener("click", function () {
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
Array.from(staged.files).forEach(function (f, i) {
|
||||||
|
if (i !== idx) dt.items.add(f);
|
||||||
|
});
|
||||||
|
staged = dt;
|
||||||
|
input.files = staged.files;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
row.appendChild(name);
|
||||||
|
row.appendChild(removeBtn);
|
||||||
|
listEl.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener("change", function () {
|
||||||
|
if (!staged) return; // kein DataTransfer-Support -- natives Verhalten greift
|
||||||
|
Array.from(input.files).forEach(function (file) { staged.items.add(file); });
|
||||||
|
input.files = staged.files;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Beim (Wieder-)Oeffnen des Modals eine frische Sammlung starten, statt
|
||||||
|
// Dateien aus einem vorherigen, bereits abgeschickten Upload-Vorgang
|
||||||
|
// versehentlich mitzuschleppen.
|
||||||
|
document.querySelectorAll('[data-open-modal="uploadModal"]').forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
try { staged = new DataTransfer(); } catch (e) { staged = null; }
|
||||||
|
input.value = "";
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* ---------------- Baum-Navigation (Freigaben links) ---------------- */
|
||||||
|
|
||||||
|
function buildTreeNode(share, path, name) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "tree-node";
|
||||||
|
li.dataset.share = share;
|
||||||
|
li.dataset.path = path;
|
||||||
|
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "tree-row";
|
||||||
|
|
||||||
|
const toggle = document.createElement("button");
|
||||||
|
toggle.type = "button";
|
||||||
|
toggle.className = "tree-toggle";
|
||||||
|
toggle.textContent = "▶";
|
||||||
|
toggle.setAttribute("aria-expanded", "false");
|
||||||
|
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "tree-label";
|
||||||
|
label.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;margin-right:5px;vertical-align:-2px;color:var(--accent);"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>';
|
||||||
|
label.append(document.createTextNode(name));
|
||||||
|
|
||||||
|
row.appendChild(toggle);
|
||||||
|
row.appendChild(label);
|
||||||
|
|
||||||
|
const childUl = document.createElement("ul");
|
||||||
|
childUl.className = "tree-children hidden";
|
||||||
|
|
||||||
|
li.appendChild(row);
|
||||||
|
li.appendChild(childUl);
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTreeNode(btn) {
|
||||||
|
const li = btn.closest(".tree-node");
|
||||||
|
const childUl = li.querySelector(":scope > .tree-children");
|
||||||
|
if (childUl.classList.contains("hidden")) {
|
||||||
|
if (childUl.dataset.loaded === "1") {
|
||||||
|
childUl.classList.remove("hidden");
|
||||||
|
btn.textContent = "▼";
|
||||||
|
btn.setAttribute("aria-expanded", "true");
|
||||||
|
} else {
|
||||||
|
loadTreeChildren(li, childUl, btn);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
childUl.classList.add("hidden");
|
||||||
|
btn.textContent = "▶";
|
||||||
|
btn.setAttribute("aria-expanded", "false");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTreeChildren(li, childUl, btn) {
|
||||||
|
const share = li.dataset.share;
|
||||||
|
const path = li.dataset.path;
|
||||||
|
const prevLabel = btn.textContent;
|
||||||
|
btn.textContent = "…";
|
||||||
|
fetch(FILESHARE_SUBFOLDERS_URL + "?share=" + encodeURIComponent(share) + "&path=" + encodeURIComponent(path))
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
childUl.innerHTML = "";
|
||||||
|
(data.folders || []).forEach(name => {
|
||||||
|
const childPath = path ? path + "/" + name : name;
|
||||||
|
childUl.appendChild(buildTreeNode(share, childPath, name));
|
||||||
|
});
|
||||||
|
childUl.dataset.loaded = "1";
|
||||||
|
childUl.classList.remove("hidden");
|
||||||
|
btn.textContent = "▼";
|
||||||
|
btn.setAttribute("aria-expanded", "true");
|
||||||
|
})
|
||||||
|
.catch(() => { btn.textContent = prevLabel; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateTree(share, path) {
|
||||||
|
window.location.href = FILESHARE_BASE_URL + "?share=" + encodeURIComponent(share) + "&path=" + encodeURIComponent(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("fileshareTree").addEventListener("click", function (e) {
|
||||||
|
const toggle = e.target.closest(".tree-toggle");
|
||||||
|
if (toggle) { toggleTreeNode(toggle); return; }
|
||||||
|
const label = e.target.closest(".tree-label");
|
||||||
|
if (label) {
|
||||||
|
const li = label.closest(".tree-node");
|
||||||
|
navigateTree(li.dataset.share, li.dataset.path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------------- Datei-Vorschau ---------------- */
|
||||||
|
|
||||||
|
const fileTableEl = document.getElementById("fileTable");
|
||||||
|
if (fileTableEl) {
|
||||||
|
fileTableEl.addEventListener("click", function (e) {
|
||||||
|
const btn = e.target.closest("[data-preview-kind]");
|
||||||
|
if (!btn) return;
|
||||||
|
openPreview(btn.dataset.previewKind, btn.dataset.previewName, btn.dataset.previewUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPreview(kind, name, url) {
|
||||||
|
document.getElementById("previewTitle").textContent = name;
|
||||||
|
const body = document.getElementById("previewBody");
|
||||||
|
body.innerHTML = "";
|
||||||
|
PoeUI.openModal("previewModal");
|
||||||
|
|
||||||
|
if (kind === "pdf") {
|
||||||
|
const iframe = document.createElement("iframe");
|
||||||
|
iframe.src = url;
|
||||||
|
iframe.style.width = "100%";
|
||||||
|
iframe.style.height = "70vh";
|
||||||
|
iframe.style.border = "0";
|
||||||
|
body.appendChild(iframe);
|
||||||
|
} else if (kind === "image") {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = url;
|
||||||
|
img.alt = name;
|
||||||
|
img.style.maxWidth = "100%";
|
||||||
|
img.style.display = "block";
|
||||||
|
img.style.margin = "0 auto";
|
||||||
|
body.appendChild(img);
|
||||||
|
} else if (kind === "text") {
|
||||||
|
body.textContent = "Lade …";
|
||||||
|
// Nur die ersten 512KB anfordern -- bei sehr großen Textdateien
|
||||||
|
// (Logs etc.) reicht das für eine Vorschau, ohne alles auf einmal
|
||||||
|
// laden zu müssen. Server unterstützt Range ueber send_file(conditional=True).
|
||||||
|
fetch(url, { headers: { "Range": "bytes=0-524287" } })
|
||||||
|
.then(r => {
|
||||||
|
// r.status ist bei einem Range-Request praktisch immer 206,
|
||||||
|
// auch wenn die Datei kleiner als die angefragten 512KB ist
|
||||||
|
// (der Server liefert dann trotzdem "206" mit der kompletten
|
||||||
|
// Datei) -- ob wirklich abgeschnitten wurde, steht nur im
|
||||||
|
// Content-Range-Header ("bytes 0-524287/<Gesamtgroesse>").
|
||||||
|
const contentRange = r.headers.get("Content-Range") || "";
|
||||||
|
const m = contentRange.match(/\/(\d+)$/);
|
||||||
|
const truncated = !!m && parseInt(m[1], 10) > 524288;
|
||||||
|
return r.text().then(text => ({ text: text, truncated: truncated }));
|
||||||
|
})
|
||||||
|
.then(({ text, truncated }) => {
|
||||||
|
body.innerHTML = "";
|
||||||
|
const pre = document.createElement("pre");
|
||||||
|
pre.style.whiteSpace = "pre-wrap";
|
||||||
|
pre.style.wordBreak = "break-word";
|
||||||
|
pre.style.fontSize = "12.5px";
|
||||||
|
pre.textContent = text;
|
||||||
|
body.appendChild(pre);
|
||||||
|
if (truncated) {
|
||||||
|
const hint = document.createElement("div");
|
||||||
|
hint.className = "text-faint";
|
||||||
|
hint.style.marginTop = "10px";
|
||||||
|
hint.style.fontSize = "12px";
|
||||||
|
hint.textContent = "Nur die ersten 512 KB angezeigt — bitte herunterladen für die komplette Datei.";
|
||||||
|
body.appendChild(hint);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { body.textContent = "Vorschau konnte nicht geladen werden."; });
|
||||||
|
} else if (kind === "docx") {
|
||||||
|
body.textContent = "Lade …";
|
||||||
|
fetch(url).then(r => r.arrayBuffer())
|
||||||
|
.then(buf => mammoth.convertToHtml({ arrayBuffer: buf }))
|
||||||
|
.then(result => {
|
||||||
|
body.innerHTML = "";
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "docx-preview";
|
||||||
|
// mammoth erzeugt kontrolliertes HTML aus Words eigenem,
|
||||||
|
// begrenztem Formatierungsmodell (Absätze/Tabellen/Listen/
|
||||||
|
// Bilder) -- kein beliebiges, aus der Datei uebernommenes
|
||||||
|
// Skript kann darin stecken, anders als bei generischem
|
||||||
|
// "fremdes HTML direkt einbetten".
|
||||||
|
wrap.innerHTML = result.value;
|
||||||
|
body.appendChild(wrap);
|
||||||
|
})
|
||||||
|
.catch(() => { body.textContent = "Vorschau konnte nicht geladen werden (Format evtl. nicht unterstützt)."; });
|
||||||
|
} else if (kind === "xlsx") {
|
||||||
|
body.textContent = "Lade …";
|
||||||
|
fetch(url).then(r => r.arrayBuffer())
|
||||||
|
.then(buf => {
|
||||||
|
const wb = XLSX.read(buf, { type: "array" });
|
||||||
|
body.innerHTML = "";
|
||||||
|
wb.SheetNames.forEach(function (sheetName, idx) {
|
||||||
|
const rows = XLSX.utils.sheet_to_json(wb.Sheets[sheetName], { header: 1, defval: "" });
|
||||||
|
const h4 = document.createElement("div");
|
||||||
|
h4.className = "xlsx-preview-sheet-title";
|
||||||
|
h4.textContent = sheetName;
|
||||||
|
body.appendChild(h4);
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.style.overflowX = "auto";
|
||||||
|
const table = document.createElement("table");
|
||||||
|
table.className = "data-table";
|
||||||
|
// Zellenwerte bewusst per textContent statt ueber die
|
||||||
|
// eingebaute HTML-Ausgabe von SheetJS gesetzt -- so ist
|
||||||
|
// die Vorschau unabhaengig von deren Escaping-Verhalten
|
||||||
|
// garantiert sicher gegen Inhalte in den Zellen.
|
||||||
|
rows.forEach(function (row) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
row.forEach(function (cell) {
|
||||||
|
const td = document.createElement("td");
|
||||||
|
td.textContent = (cell === null || cell === undefined) ? "" : String(cell);
|
||||||
|
tr.appendChild(td);
|
||||||
|
});
|
||||||
|
table.appendChild(tr);
|
||||||
|
});
|
||||||
|
wrap.appendChild(table);
|
||||||
|
body.appendChild(wrap);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => { body.textContent = "Vorschau konnte nicht geladen werden."; });
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+101
-76
@@ -4,6 +4,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">{{ groups|length + 1 }} Gruppen · Rechteverwaltung</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">{{ groups|length + 1 }} Gruppen · Rechteverwaltung</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
|
||||||
{% macro permission_table(group, group_key, checked_keys, readonly) %}
|
{% macro permission_table(group, group_key, checked_keys, readonly) %}
|
||||||
{% set row_types = group_row_types[group_key] %}
|
{% set row_types = group_row_types[group_key] %}
|
||||||
@@ -69,11 +70,7 @@
|
|||||||
|
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="font-size:16px;">Gruppen</h2>
|
<h2 style="font-size:16px;">Gruppen {{ hi.hint_icon("Rechte je Gruppe granular vergeben, Mitgliedschaft in mehreren Gruppen addiert sich. „Admin“ und „Benutzer“ sind feste Systemgruppen. Legende direkt bei den Rechten.", "Gruppen") }}</h2>
|
||||||
<div class="hint">
|
|
||||||
Rechte je Gruppe granular vergeben, Mitgliedschaft in mehreren Gruppen addiert sich.
|
|
||||||
„Admin“ und „Benutzer“ sind feste Systemgruppen. Legende direkt bei den Rechten.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.has_permission('groups.create') %}
|
{% if current_user.has_permission('groups.create') %}
|
||||||
<button type="button" class="btn btn-primary" data-open-modal="addGroupModal">
|
<button type="button" class="btn btn-primary" data-open-modal="addGroupModal">
|
||||||
@@ -104,8 +101,8 @@
|
|||||||
<td class="text-dim">{{ admin_virtual_group.member_names|length }}</td>
|
<td class="text-dim">{{ admin_virtual_group.member_names|length }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<button class="icon-btn" title="Rechte anzeigen" onclick="toggleDetail('detail-admin')">
|
<button class="icon-btn" title="Rechte anzeigen" data-open-modal="adminGroupModal">
|
||||||
<svg id="chev-admin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
|
<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>
|
</button>
|
||||||
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="adminMembersModal">
|
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="adminMembersModal">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/></svg>
|
||||||
@@ -113,16 +110,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="group-detail-row hidden" id="detail-admin">
|
|
||||||
<td colspan="3">
|
|
||||||
{{ permission_tree(admin_virtual_group.permissions, true, true) }}
|
|
||||||
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Admins dürfen immer alles — diese Rechte sind fest und nicht änderbar.</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
||||||
{% for g in groups %}
|
{% for g in groups %}
|
||||||
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
|
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
|
||||||
|
{% set can_unlock_system = g.is_system and current_user.is_admin %}
|
||||||
<tbody data-sort-name="{{ g.name|lower }}" data-sort-members="{{ g.member_names|length }}">
|
<tbody data-sort-name="{{ g.name|lower }}" data-sort-members="{{ g.member_names|length }}">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="cell-name">
|
<td class="cell-name">
|
||||||
@@ -132,8 +124,12 @@
|
|||||||
<td class="text-dim">{{ g.member_names|length }}</td>
|
<td class="text-dim">{{ g.member_names|length }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<button class="icon-btn" title="Rechte anzeigen{{ '/bearbeiten' if can_edit_this else '' }}" onclick="toggleDetail('detail-{{ g.id }}')">
|
<button class="icon-btn" title="{{ 'Bearbeiten' if (can_edit_this or can_unlock_system) else 'Anzeigen' }}" data-open-modal="editGroupModal{{ loop.index }}">
|
||||||
<svg id="chev-{{ g.id }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
|
{% if can_edit_this or can_unlock_system %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||||
|
{% else %}
|
||||||
|
<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>
|
||||||
|
{% endif %}
|
||||||
</button>
|
</button>
|
||||||
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="membersModal{{ loop.index }}">
|
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="membersModal{{ loop.index }}">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/></svg>
|
||||||
@@ -149,57 +145,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% set can_unlock_system = g.is_system and current_user.is_admin %}
|
|
||||||
<tr class="group-detail-row hidden" id="detail-{{ g.id }}">
|
|
||||||
<td colspan="3">
|
|
||||||
{% if can_edit_this %}
|
|
||||||
<form method="post">
|
|
||||||
<input type="hidden" name="save_group" value="1">
|
|
||||||
<input type="hidden" name="permissions_submitted" value="1">
|
|
||||||
<input type="hidden" name="group_id" value="{{ g.id }}">
|
|
||||||
<input type="hidden" name="name" value="{{ g.name }}">
|
|
||||||
{{ permission_tree(g.permissions, false, true) }}
|
|
||||||
<div class="flex" style="justify-content:flex-end; margin-top:16px;">
|
|
||||||
<button type="submit" class="btn btn-primary btn-sm">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
|
||||||
Rechte speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% elif can_unlock_system %}
|
|
||||||
<div id="readonly-{{ g.id }}">
|
|
||||||
{{ permission_tree(g.permissions, true, true) }}
|
|
||||||
<div class="flex" style="justify-content:space-between; align-items:center; margin-top:12px;">
|
|
||||||
<p class="text-faint" style="font-size:11.5px; margin:0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind normalerweise fest.</p>
|
|
||||||
<button type="button" class="btn btn-secondary btn-sm" onclick="unlockSystemGroup({{ g.id }})">Freischalten</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form method="post" class="hidden" id="unlock-{{ g.id }}">
|
|
||||||
<input type="hidden" name="save_group" value="1">
|
|
||||||
<input type="hidden" name="permissions_submitted" value="1">
|
|
||||||
<input type="hidden" name="unlock_system_group" value="1">
|
|
||||||
<input type="hidden" name="group_id" value="{{ g.id }}">
|
|
||||||
<input type="hidden" name="name" value="{{ g.name }}">
|
|
||||||
{{ permission_tree(g.permissions, false, true) }}
|
|
||||||
<p class="text-faint" style="font-size:11.5px; margin:12px 0;">
|
|
||||||
⚠ Diese Gruppe ist die Standardgruppe für neue Benutzer (auch neu angelegte AD/LDAP-Konten). Zu restriktive
|
|
||||||
Rechte hier können den Erst-Login neuer Konten einschränken.
|
|
||||||
</p>
|
|
||||||
<div class="flex" style="justify-content:flex-end;">
|
|
||||||
<button type="submit" class="btn btn-primary btn-sm">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
|
||||||
Rechte speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% else %}
|
|
||||||
{{ permission_tree(g.permissions, true, true) }}
|
|
||||||
{% if g.is_system %}
|
|
||||||
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind fest und nicht änderbar.</p>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tbody data-sort-pinned>
|
<tbody data-sort-pinned>
|
||||||
@@ -210,6 +155,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="adminGroupModal">
|
||||||
|
<div class="modal" style="max-width:1000px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Admin — Rechte</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
{{ permission_tree(admin_virtual_group.permissions, true, true) }}
|
||||||
|
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Admins dürfen immer alles — diese Rechte sind fest und nicht änderbar.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="adminMembersModal">
|
<div class="modal-overlay" id="adminMembersModal">
|
||||||
<div class="modal" style="max-width:380px;">
|
<div class="modal" style="max-width:380px;">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
@@ -238,6 +196,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% for g in groups %}
|
{% for g in groups %}
|
||||||
|
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
|
||||||
|
{% set can_unlock_system = g.is_system and current_user.is_admin %}
|
||||||
<div class="modal-overlay" id="membersModal{{ loop.index }}">
|
<div class="modal-overlay" id="membersModal{{ loop.index }}">
|
||||||
<div class="modal" style="max-width:380px;">
|
<div class="modal" style="max-width:380px;">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
@@ -268,6 +228,80 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="editGroupModal{{ loop.index }}">
|
||||||
|
<div class="modal" style="max-width:1000px;">
|
||||||
|
{% if can_edit_this %}
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="save_group" value="1">
|
||||||
|
<input type="hidden" name="permissions_submitted" value="1">
|
||||||
|
<input type="hidden" name="group_id" value="{{ g.id }}">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Gruppe bearbeiten</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>Name</label>
|
||||||
|
<input type="text" name="name" value="{{ g.name }}" required>
|
||||||
|
</div>
|
||||||
|
{{ permission_tree(g.permissions, false, true) }}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% elif can_unlock_system %}
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Gruppe „{{ g.name }}“</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field"><label>Name</label><input type="text" value="{{ g.name }}" disabled></div>
|
||||||
|
<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; flex-wrap:wrap;">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Gruppe „{{ g.name }}“</h3>
|
||||||
|
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
{{ permission_tree(g.permissions, true, true) }}
|
||||||
|
{% if g.is_system %}
|
||||||
|
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind fest und nicht änderbar.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="modal-overlay" id="addGroupModal">
|
<div class="modal-overlay" id="addGroupModal">
|
||||||
@@ -280,9 +314,8 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<input type="hidden" name="add_group" value="1">
|
<input type="hidden" name="add_group" value="1">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Name</label>
|
<label>Name {{ hi.hint_icon("Mitglieder werden danach über die Gruppentabelle zugeordnet.", "Name") }}</label>
|
||||||
<input type="text" name="name" required placeholder="z.B. Facility-Team">
|
<input type="text" name="name" required placeholder="z.B. Facility-Team">
|
||||||
<div class="field-hint">Mitglieder werden danach über die Gruppentabelle zugeordnet.</div>
|
|
||||||
</div>
|
</div>
|
||||||
{{ permission_tree([], false) }}
|
{{ permission_tree([], false) }}
|
||||||
</div>
|
</div>
|
||||||
@@ -317,14 +350,6 @@ function unlockSystemGroup(id) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleDetail(id) {
|
|
||||||
const row = document.getElementById(id);
|
|
||||||
if (!row) return;
|
|
||||||
row.classList.toggle("hidden");
|
|
||||||
const chev = document.getElementById(id.replace("detail-", "chev-"));
|
|
||||||
if (chev) chev.style.transform = row.classList.contains("hidden") ? "" : "rotate(180deg)";
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyPermissionGating() {
|
function applyPermissionGating() {
|
||||||
document.querySelectorAll(".permission-group-col").forEach(function (area) {
|
document.querySelectorAll(".permission-group-col").forEach(function (area) {
|
||||||
const toggle = area.querySelector(".permission-area-toggle-cb");
|
const toggle = area.querySelector(".permission-area-toggle-cb");
|
||||||
|
|||||||
@@ -4,11 +4,13 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">{{ selected_file.range_label if selected_file else "kein Log" }}</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">{{ selected_file.range_label if selected_file else "kein Log" }}</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="font-size:16px;">Log-Verlauf</h2>
|
<h2 style="font-size:16px;">Log-Verlauf</h2>
|
||||||
<div class="hint">Aktuelles Live-Log sowie ältere, von logrotate rotierte Kopien davon — {{ files|length }} Stand{{ 'e' if files|length != 1 else '' }} verfügbar, auswählbar nach Zeitraum. Aus Performance-Gründen unformatiert (RAW) dargestellt, ohne farbliche Aufbereitung.</div>
|
<div class="hint">Aktuelles Live-Log sowie ältere, von logrotate rotierte Kopien davon — {{ files|length }} Stand{{ 'e' if files|length != 1 else '' }} verfügbar, auswählbar nach Zeitraum. Aus Performance-Gründen unformatiert (RAW) dargestellt, ohne farbliche Aufbereitung.</div>
|
||||||
|
<div class="hint">Zusätzlich weiter unten: bereits archivierte Auditlog-Tage (das Auditlog wird ab {{ "{:,}".format(audit_threshold).replace(",", ".") }} Einträgen automatisch tageweise archiviert, bis {{ "{:,}".format(audit_target).replace(",", ".") }} Einträge unterschritten sind).</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href="{{ url_for('logs') }}" class="btn btn-secondary">
|
<a href="{{ url_for('logs') }}" class="btn btn-secondary">
|
||||||
@@ -16,7 +18,7 @@
|
|||||||
Zurück zur Live-Ansicht
|
Zurück zur Live-Ansicht
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="btn btn-secondary"
|
<button type="button" class="btn btn-secondary"
|
||||||
onclick="openRawLogModal('{{ url_for('logs_history_raw', file=selected_name) if selected_name else '' }}', 'Komplettes Log (RAW) — {{ selected_file.range_label if selected_file else '' }}')"
|
onclick="openRawLogModal('{{ url_for('logs_history_raw', file=selected_name) if selected_name else '' }}', 'Komplettes Log (RAW) — {{ selected_file.range_label if selected_file else '' }}', true)"
|
||||||
{% if not selected_name %}disabled{% endif %}>
|
{% if not selected_name %}disabled{% endif %}>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
|
||||||
Komplettes Log (RAW)
|
Komplettes Log (RAW)
|
||||||
@@ -50,6 +52,54 @@
|
|||||||
<pre id="log-box" class="raw-log-content" style="height:100%;">{{ log_content or "" }}</pre>
|
<pre id="log-box" class="raw-log-content" style="height:100%;">{{ log_content or "" }}</pre>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="section-head" style="margin-top:28px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:16px;">Archivierte Auditlog-Tage {{ hi.hint_icon("Ältere Auditlog-Einträge, tageweise als eigene Datei ausgelagert, sobald die laufende Tabelle den Schwellenwert überschreitet — die Dateien selbst bleiben unbegrenzt erhalten, bis sie hier bewusst exportiert werden.", "Archivierte Auditlog-Tage") }}</h2>
|
||||||
|
</div>
|
||||||
|
{% if current_user.can_manage_log_history and audit_files %}
|
||||||
|
<form method="post" action="{{ url_for('logs_history_audit_export') }}"
|
||||||
|
data-confirm="Alle {{ audit_files|length }} archivierten Auditlog-Datei(en) als ZIP herunterladen und danach vom Server löschen?"
|
||||||
|
data-confirm-title="Export & Löschen">
|
||||||
|
<button type="submit" class="btn btn-secondary">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
|
||||||
|
Alle exportieren & löschen (ZIP)
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if disk_warning %}
|
||||||
|
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;">{{ disk_warning }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not audit_files %}
|
||||||
|
<p class="text-faint" style="font-size:12.5px;">Noch keine archivierten Auditlog-Tage vorhanden.</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th style="width:1%; white-space:nowrap;">Tag</th>
|
||||||
|
<th style="width:1%; white-space:nowrap;">Größe</th>
|
||||||
|
<th></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for f in audit_files %}
|
||||||
|
<tr>
|
||||||
|
<td class="mono">{{ f.day }}</td>
|
||||||
|
<td class="text-dim mono" style="font-size:12.5px;">{{ (f.size / 1024)|round(1) }} KB</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm"
|
||||||
|
onclick="openRawLogModal('{{ url_for('logs_history_audit_raw', file=f.filename) }}', 'Auditlog-Archiv — {{ f.day }}', true)">
|
||||||
|
Ansehen (RAW)
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
|||||||
@@ -4,15 +4,11 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Linux-Client{{ 's' if devices|length != 1 else '' }} mit SSH-Zugangsdaten</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Linux-Client{{ 's' if devices|length != 1 else '' }} mit SSH-Zugangsdaten</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="font-size:16px;">Wartung (SSH)</h2>
|
<h2 style="font-size:16px;">Wartung (SSH) {{ hi.hint_icon("Bulk-Update (apt update & upgrade -y) und Neustart per SSH direkt auf dem Gerät — unabhängig vom PoE-Neustart über den Switch (siehe Dashboard). Nur Geräte mit hinterlegten SSH-Zugangsdaten der Kategorie „Linux-Client“ erscheinen hier.", "Wartung (SSH)") }}</h2>
|
||||||
<div class="hint">
|
|
||||||
Bulk-Update (<code>apt update & upgrade -y</code>) und Neustart per SSH direkt auf dem Gerät —
|
|
||||||
unabhängig vom PoE-Neustart über den Switch (siehe Dashboard). Nur Geräte mit hinterlegten
|
|
||||||
SSH-Zugangsdaten der Kategorie „Linux-Client“ erscheinen hier.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -87,7 +83,9 @@
|
|||||||
|
|
||||||
{% if can_run %}
|
{% if can_run %}
|
||||||
<div class="section-head" style="margin-top:16px;">
|
<div class="section-head" style="margin-top:16px;">
|
||||||
<div class="hint">Ausgewählte Geräte aktualisieren (nicht-interaktiv, unbeaufsichtigte Konfig-Rückfragen werden automatisch mit den bisherigen Werten beantwortet) oder neu starten.</div>
|
<div style="display:flex; align-items:center; gap:6px; font-size:12.5px; color:var(--text-faint);">
|
||||||
|
Bulk-Aktionen {{ hi.hint_icon("Ausgewählte Geräte aktualisieren (nicht-interaktiv, unbeaufsichtigte Konfig-Rückfragen werden automatisch mit den bisherigen Werten beantwortet) oder neu starten.", "Bulk-Aktionen") }}
|
||||||
|
</div>
|
||||||
<div style="display:flex; gap:8px;">
|
<div style="display:flex; gap:8px;">
|
||||||
<button type="button" class="btn btn-secondary" onclick="bulkReboot()">
|
<button type="button" class="btn btn-secondary" onclick="bulkReboot()">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">Prüfintervall und Netzwerkkonfiguration dieses Hosts</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">Prüfintervall und Netzwerkkonfiguration dieses Hosts</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
|
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
@@ -27,13 +28,12 @@
|
|||||||
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
|
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="timezone">Zeitzone</label>
|
<label for="timezone">Zeitzone {{ hi.hint_icon("Bestimmt die lokale Zeit in allen Logs und im Änderungsverlauf — wirkt sofort für diese laufende App-Instanz, ohne Dienst-Neustart.", "Zeitzone") }}</label>
|
||||||
<select name="timezone" id="timezone" required>
|
<select name="timezone" id="timezone" required>
|
||||||
{% for tz in timezones %}
|
{% for tz in timezones %}
|
||||||
<option value="{{ tz }}" {% if tz == current_timezone %}selected{% endif %}>{{ tz }}</option>
|
<option value="{{ tz }}" {% if tz == current_timezone %}selected{% endif %}>{{ tz }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<div class="field-hint">Bestimmt die lokale Zeit in allen Logs und im Änderungsverlauf — wirkt sofort für diese laufende App-Instanz, ohne Dienst-Neustart.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-block">
|
<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>
|
<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>
|
||||||
@@ -43,9 +43,8 @@
|
|||||||
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
|
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="interval">Prüfintervall (Minuten)</label>
|
<label for="interval">Prüfintervall (Minuten) {{ hi.hint_icon("Wie oft Geräte auf Erreichbarkeit geprüft werden. Der Hintergrund-Dienst (tesm-check.service) wird nach dem Speichern automatisch neu gestartet.", "Prüfintervall (Minuten)") }}</label>
|
||||||
<input type="number" name="interval" id="interval" value="{{ interval }}" min="1" required>
|
<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 (tesm-check.service) wird nach dem Speichern automatisch neu gestartet.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-block">
|
<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>
|
<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>
|
||||||
@@ -118,13 +117,12 @@
|
|||||||
{% elif net_backend != 'unknown' and current_user.has_permission('settings_system.edit') %}
|
{% elif net_backend != 'unknown' and current_user.has_permission('settings_system.edit') %}
|
||||||
<form method="post" data-confirm="Netzwerkkonfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ net_revert_seconds }} Sekunden wiederhergestellt.">
|
<form method="post" data-confirm="Netzwerkkonfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ net_revert_seconds }} Sekunden wiederhergestellt.">
|
||||||
<input type="hidden" name="apply_network" value="1">
|
<input type="hidden" name="apply_network" value="1">
|
||||||
<div class="field"><label>Interface</label>
|
<div class="field"><label>Interface {{ hi.hint_icon("Jedes Interface hat seine eigene Konfiguration — ein Wechsel hier lädt unten dessen tatsächlichen Ist-Zustand, ändert aber noch nichts.", "Interface") }}</label>
|
||||||
<select name="net_interface" id="netInterfaceSelect" onchange="loadNetworkState(this.value)">
|
<select name="net_interface" id="netInterfaceSelect" onchange="loadNetworkState(this.value)">
|
||||||
{% for iface in net_interfaces %}
|
{% for iface in net_interfaces %}
|
||||||
<option value="{{ iface }}" {% if iface == net_interface %}selected{% endif %}>{{ iface }}</option>
|
<option value="{{ iface }}" {% if iface == net_interface %}selected{% endif %}>{{ iface }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<div class="field-hint">Jedes Interface hat seine eigene Konfiguration — ein Wechsel hier lädt unten dessen tatsächlichen Ist-Zustand, ändert aber noch nichts.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Modus</label>
|
<div class="field"><label>Modus</label>
|
||||||
<select name="net_mode" id="netModeSelect" onchange="document.getElementById('netStaticFields').classList.toggle('hidden', this.value !== 'static')">
|
<select name="net_mode" id="netModeSelect" onchange="document.getElementById('netStaticFields').classList.toggle('hidden', this.value !== 'static')">
|
||||||
@@ -143,9 +141,8 @@
|
|||||||
<input type="text" name="net_gateway" id="netGatewayInput" value="{{ net_state.gateway if net_state.mode == 'static' else '' }}" placeholder="z.B. 192.168.1.1">
|
<input type="text" name="net_gateway" id="netGatewayInput" value="{{ net_state.gateway if net_state.mode == 'static' else '' }}" placeholder="z.B. 192.168.1.1">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>DNS-Server</label>
|
<div class="field"><label>DNS-Server {{ hi.hint_icon("Kommagetrennt. Leer lassen, um die per DHCP zugewiesenen DNS-Server zu verwenden.", "DNS-Server") }}</label>
|
||||||
<input type="text" name="net_dns" id="netDnsInput" value="{{ net_state.dns|join(', ') if net_state and net_state.dns else '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8">
|
<input type="text" name="net_dns" id="netDnsInput" value="{{ net_state.dns|join(', ') if net_state and net_state.dns else '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8">
|
||||||
<div class="field-hint">Kommagetrennt. Leer lassen, um die per DHCP zugewiesenen DNS-Server zu verwenden.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-block">
|
<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>
|
<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>
|
||||||
@@ -184,9 +181,8 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="log_rotation_keep">Aufbewahrung (Anzahl Rotationen)</label>
|
<label for="log_rotation_keep">Aufbewahrung (Anzahl Rotationen) {{ hi.hint_icon("Standard: wöchentlich, 4 Rotationen (≈ 1 Monat Historie je Log).", "Aufbewahrung (Anzahl Rotationen)") }}</label>
|
||||||
<input type="number" name="log_rotation_keep" id="log_rotation_keep" value="{{ log_rotation_keep }}" min="1" required>
|
<input type="number" name="log_rotation_keep" id="log_rotation_keep" value="{{ log_rotation_keep }}" min="1" required>
|
||||||
<div class="field-hint">Standard: wöchentlich, 4 Rotationen (≈ 1 Monat Historie je Log).</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-block">
|
<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>
|
<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>
|
||||||
@@ -221,9 +217,8 @@
|
|||||||
{% if current_user.has_permission('settings_system.edit') %}
|
{% if current_user.has_permission('settings_system.edit') %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="trash_retention_days">Aufbewahrungsdauer (Tage)</label>
|
<label for="trash_retention_days">Aufbewahrungsdauer (Tage) {{ hi.hint_icon("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).", "Aufbewahrungsdauer (Tage)") }}</label>
|
||||||
<input type="number" name="trash_retention_days" id="trash_retention_days" value="{{ trash_retention_days }}" min="1" required>
|
<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>
|
</div>
|
||||||
<button type="submit" name="save_trash_retention" value="1" 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>
|
<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>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">Reservierungen und eigene Options aus den Client-Stammdaten (Kea DHCP)</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">Reservierungen und eigene Options aus den Client-Stammdaten (Kea DHCP)</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
|
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
@@ -81,9 +82,8 @@
|
|||||||
<div class="field"><label>Lease-Zeit Maximum (Sek.)</label>
|
<div class="field"><label>Lease-Zeit Maximum (Sek.)</label>
|
||||||
<input type="number" name="dhcp_lease_max" value="{{ cfg.dhcp_lease_max }}">
|
<input type="number" name="dhcp_lease_max" value="{{ cfg.dhcp_lease_max }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Ausgabepfad (Kea-Konfigurationsdatei)</label>
|
<div class="field"><label>Ausgabepfad (Kea-Konfigurationsdatei) {{ hi.hint_icon("Standardmäßig die aktive Kea-Konfiguration. Läuft der Dienst gerade, schreibt „Speichern“ die Datei neu und startet ihn automatisch neu.", "Ausgabepfad (Kea-Konfigurationsdatei)") }}</label>
|
||||||
<input type="text" name="dhcp_output_path" value="{{ cfg.dhcp_output_path }}" class="mono">
|
<input type="text" name="dhcp_output_path" value="{{ cfg.dhcp_output_path }}" class="mono">
|
||||||
<div class="field-hint">Standardmäßig die aktive Kea-Konfiguration. Läuft der Dienst gerade, schreibt „Speichern“ die Datei neu und startet ihn automatisch neu.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-block">
|
<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>
|
<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>
|
||||||
@@ -98,11 +98,7 @@
|
|||||||
<div class="card card-pad" style="grid-column:1 / -1;">
|
<div class="card card-pad" style="grid-column:1 / -1;">
|
||||||
<div class="section-head" style="margin-bottom:16px;">
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="font-size:16px;">Subnetze</h2>
|
<h2 style="font-size:16px;">Subnetze {{ hi.hint_icon("Ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz bedienen — je Subnetz eigene Range, optional eigenes Gateway/DNS. Subnet/Netzmaske werden live vom Interface übernommen, nicht manuell gepflegt.", "Subnetze") }}</h2>
|
||||||
<div class="hint">
|
|
||||||
Ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz bedienen — je Subnetz eigene Range,
|
|
||||||
optional eigenes Gateway/DNS. Subnet/Netzmaske werden live vom Interface übernommen, nicht manuell gepflegt.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<button type="button" class="btn btn-primary" data-open-modal="addSubnetModal">
|
<button type="button" class="btn btn-primary" data-open-modal="addSubnetModal">
|
||||||
@@ -175,11 +171,7 @@
|
|||||||
<div class="card card-pad" style="grid-column:1 / -1;">
|
<div class="card card-pad" style="grid-column:1 / -1;">
|
||||||
<div class="section-head" style="margin-bottom:16px;">
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="font-size:16px;">DHCP-Options</h2>
|
<h2 style="font-size:16px;">DHCP-Options {{ hi.hint_icon('Standard-Optionen (analog den "Predefined Options" eines Windows-DHCP-Servers) sind bereits vorbefüllt, eigene/herstellerspezifische Options lassen sich zusätzlich anlegen — beide global oder pro Client überschreibbar.', "DHCP-Options") }}</h2>
|
||||||
<div class="hint">
|
|
||||||
Standard-Optionen (analog den "Predefined Options" eines Windows-DHCP-Servers) sind bereits vorbefüllt,
|
|
||||||
eigene/herstellerspezifische Options lassen sich zusätzlich anlegen — beide global oder pro Client überschreibbar.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<button type="button" class="btn btn-primary" data-open-modal="addOptionModal">
|
<button type="button" class="btn btn-primary" data-open-modal="addOptionModal">
|
||||||
@@ -217,7 +209,9 @@
|
|||||||
<input type="text" name="value" value="{{ option_values.get(d.id, {}).get('', '') }}" style="max-width:220px;" placeholder="(nicht gesetzt)">
|
<input type="text" name="value" value="{{ option_values.get(d.id, {}).get('', '') }}" style="max-width:220px;" placeholder="(nicht gesetzt)">
|
||||||
<button type="submit" class="btn btn-secondary btn-sm">Speichern</button>
|
<button type="submit" class="btn btn-secondary btn-sm">Speichern</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="field-hint">Leeren Wert speichern, um eine Standard-Option wieder auszublenden.</div>
|
<div class="text-faint" style="font-size:11.5px; display:flex; align-items:center; gap:6px;">
|
||||||
|
Standard-Option zurücksetzen {{ hi.hint_icon("Leeren Wert speichern, um eine Standard-Option wieder auszublenden.", "Standard-Option zurücksetzen") }}
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ option_values.get(d.id, {}).get('', '—') }}
|
{{ option_values.get(d.id, {}).get('', '—') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -272,11 +266,7 @@
|
|||||||
<div class="card card-pad" style="grid-column:1 / -1;">
|
<div class="card card-pad" style="grid-column:1 / -1;">
|
||||||
<div class="section-head" style="margin-bottom:16px;">
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="font-size:16px;">Aktive Leases</h2>
|
<h2 style="font-size:16px;">Aktive Leases {{ hi.hint_icon("Direkt aus Kea gelesen (nicht aus den Reservierungen) — zeigt auch Clients OHNE eigene Reservierung, die sich einfach eine freie IP aus dem Pool genommen haben.", "Aktive Leases") }}</h2>
|
||||||
<div class="hint">
|
|
||||||
Direkt aus Kea gelesen (nicht aus den Reservierungen) — zeigt auch Clients OHNE eigene Reservierung,
|
|
||||||
die sich einfach eine freie IP aus dem Pool genommen haben.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -497,9 +487,8 @@
|
|||||||
<div class="field"><label>Code</label>
|
<div class="field"><label>Code</label>
|
||||||
<input type="number" name="code" min="1" max="254" required placeholder="z.B. 225">
|
<input type="number" name="code" min="1" max="254" required placeholder="z.B. 225">
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Name</label>
|
<div class="field"><label>Name {{ hi.hint_icon("Nur Kleinbuchstaben, Ziffern und Bindestrich, muss mit einem Buchstaben beginnen.", "Name") }}</label>
|
||||||
<input type="text" name="name" required placeholder="z.B. terminal-url" pattern="[a-z][a-z0-9-]*">
|
<input type="text" name="name" required placeholder="z.B. terminal-url" pattern="[a-z][a-z0-9-]*">
|
||||||
<div class="field-hint">Nur Kleinbuchstaben, Ziffern und Bindestrich, muss mit einem Buchstaben beginnen.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Datentyp</label>
|
<div class="field"><label>Datentyp</label>
|
||||||
<select name="type">
|
<select name="type">
|
||||||
@@ -539,13 +528,11 @@
|
|||||||
<div class="field"><label>MAC-Adresse</label>
|
<div class="field"><label>MAC-Adresse</label>
|
||||||
<input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF">
|
<input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF">
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>IP-Adresse</label>
|
<div class="field"><label>IP-Adresse {{ hi.hint_icon("Muss in einem der oben konfigurierten Subnetze liegen, sonst wird sie beim Schreiben übersprungen.", "IP-Adresse") }}</label>
|
||||||
<input type="text" name="ip" required placeholder="z.B. 192.168.1.50">
|
<input type="text" name="ip" required placeholder="z.B. 192.168.1.50">
|
||||||
<div class="field-hint">Muss in einem der oben konfigurierten Subnetze liegen, sonst wird sie beim Schreiben übersprungen.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Name</label>
|
<div class="field"><label>Name {{ hi.hint_icon("Wird zum Hostname der Reservierung. DHCP-Optionen (z.B. Hostname erzwingen) lassen sich danach über das Options-Symbol in der Tabelle setzen.", "Name") }}</label>
|
||||||
<input type="text" name="name" required placeholder="z.B. Empfangsdrucker">
|
<input type="text" name="name" required placeholder="z.B. Empfangsdrucker">
|
||||||
<div class="field-hint">Wird zum Hostname der Reservierung. DHCP-Optionen (z.B. Hostname erzwingen) lassen sich danach über das Options-Symbol in der Tabelle setzen.</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -575,16 +562,14 @@
|
|||||||
<div class="field"><label>Range Start</label>
|
<div class="field"><label>Range Start</label>
|
||||||
<input type="text" name="range_start" required placeholder="z.B. 192.168.1.100">
|
<input type="text" name="range_start" required placeholder="z.B. 192.168.1.100">
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Range Ende</label>
|
<div class="field"><label>Range Ende {{ hi.hint_icon("Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.", "Range Ende") }}</label>
|
||||||
<input type="text" name="range_end" required placeholder="z.B. 192.168.1.200">
|
<input type="text" name="range_end" required placeholder="z.B. 192.168.1.200">
|
||||||
<div class="field-hint">Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Gateway (optional)</label>
|
<div class="field"><label>Gateway (optional)</label>
|
||||||
<input type="text" name="gateway" placeholder="Automatisch: erkanntes Gateway des Interfaces">
|
<input type="text" name="gateway" placeholder="Automatisch: erkanntes Gateway des Interfaces">
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>DNS-Server (optional)</label>
|
<div class="field"><label>DNS-Server (optional) {{ hi.hint_icon("Kommagetrennt, falls mehrere.", "DNS-Server (optional)") }}</label>
|
||||||
<input type="text" name="dns" placeholder="z.B. 1.1.1.1, 8.8.8.8">
|
<input type="text" name="dns" placeholder="z.B. 1.1.1.1, 8.8.8.8">
|
||||||
<div class="field-hint">Kommagetrennt, falls mehrere.</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -617,16 +602,14 @@
|
|||||||
<div class="field"><label>Range Start</label>
|
<div class="field"><label>Range Start</label>
|
||||||
<input type="text" name="range_start" value="{{ s.range_start }}" required>
|
<input type="text" name="range_start" value="{{ s.range_start }}" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Range Ende</label>
|
<div class="field"><label>Range Ende {{ hi.hint_icon("Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.", "Range Ende") }}</label>
|
||||||
<input type="text" name="range_end" value="{{ s.range_end }}" required>
|
<input type="text" name="range_end" value="{{ s.range_end }}" required>
|
||||||
<div class="field-hint">Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Gateway (optional)</label>
|
<div class="field"><label>Gateway (optional)</label>
|
||||||
<input type="text" name="gateway" value="{{ s.gateway or '' }}" placeholder="Automatisch: erkanntes Gateway des Interfaces">
|
<input type="text" name="gateway" value="{{ s.gateway or '' }}" placeholder="Automatisch: erkanntes Gateway des Interfaces">
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>DNS-Server (optional)</label>
|
<div class="field"><label>DNS-Server (optional) {{ hi.hint_icon("Kommagetrennt, falls mehrere.", "DNS-Server (optional)") }}</label>
|
||||||
<input type="text" name="dns" value="{{ s.dns or '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8">
|
<input type="text" name="dns" value="{{ s.dns or '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8">
|
||||||
<div class="field-hint">Kommagetrennt, falls mehrere.</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">Umzug auf eine neue Umgebung</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">Umzug auf eine neue Umgebung</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
<div class="settings-grid">
|
<div class="settings-grid">
|
||||||
|
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
@@ -26,7 +27,7 @@
|
|||||||
data-confirm="Ausgewählte Kategorien wirklich importieren? Bestehende Einträge mit gleichem Namen/Hostname/MAC/Benutzernamen werden überschrieben.">
|
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 }}">
|
<input type="hidden" name="import_token" value="{{ import_preview.token }}">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Was importieren?</label>
|
<label>Was importieren? {{ hi.hint_icon("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.", "Was importieren?") }}</label>
|
||||||
<div class="check-list">
|
<div class="check-list">
|
||||||
{% for s in import_preview.sections %}
|
{% for s in import_preview.sections %}
|
||||||
<label class="check-row">
|
<label class="check-row">
|
||||||
@@ -38,10 +39,6 @@
|
|||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-block">
|
<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>
|
<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>
|
||||||
@@ -77,7 +74,7 @@
|
|||||||
{% if current_user.has_permission('settings_importexport.export') %}
|
{% if current_user.has_permission('settings_importexport.export') %}
|
||||||
<form method="post" action="{{ url_for('export_data') }}">
|
<form method="post" action="{{ url_for('export_data') }}">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Was exportieren?</label>
|
<label>Was exportieren? {{ hi.hint_icon("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.", "Was exportieren?") }}</label>
|
||||||
<div class="check-list">
|
<div class="check-list">
|
||||||
{% for key, label in export_sections %}
|
{% for key, label in export_sections %}
|
||||||
<label class="check-row">
|
<label class="check-row">
|
||||||
@@ -85,20 +82,15 @@
|
|||||||
{% if key not in admin_only_sections or current_user.is_admin %}checked{% endif %}
|
{% 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 %}>
|
{% if key in admin_only_sections and not current_user.is_admin %}disabled{% endif %}>
|
||||||
{{ label }}
|
{{ label }}
|
||||||
|
{% if export_section_hints and key in export_section_hints %}{{ hi.hint_icon(export_section_hints[key], label) }}{% endif %}
|
||||||
{% if key in admin_only_sections %}<span class="pill user" style="font-size:10px; padding:2px 7px;">Nur Admin</span>{% endif %}
|
{% if key in admin_only_sections %}<span class="pill user" style="font-size:10px; padding:2px 7px;">Nur Admin</span>{% endif %}
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</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>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="export_passphrase">Passphrase</label>
|
<label for="export_passphrase">Passphrase {{ hi.hint_icon("Wird zum Verschlüsseln der Export-Datei benötigt — für den späteren Import dieselbe Passphrase erneut eingeben.", "Passphrase") }}</label>
|
||||||
<input type="password" name="export_passphrase" id="export_passphrase" required>
|
<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>
|
</div>
|
||||||
<button type="submit" class="btn btn-secondary btn-block">
|
<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>
|
<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>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
<div class="settings-grid settings-grid--wide">
|
<div class="settings-grid settings-grid--wide">
|
||||||
|
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
@@ -23,18 +24,16 @@
|
|||||||
<span>LDAP-Anmeldung aktivieren</span>
|
<span>LDAP-Anmeldung aktivieren</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Server</label>
|
<div class="field"><label>Server {{ hi.hint_icon("DNS-Name oder IP-Adresse — beides wird genau so gespeichert und beim Verbinden verwendet.", "Server") }}</label>
|
||||||
<input type="text" name="ldap_server" value="{{ ldap.server }}" placeholder="z.B. 192.168.1.1 oder dc01.firma.local">
|
<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>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
<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 %}
|
<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;">
|
onchange="document.getElementById('ldap_port').value = this.checked ? 636 : 389;">
|
||||||
<span class="track"></span>
|
<span class="track"></span>
|
||||||
<span>LDAPS/TLS verwenden</span>
|
<span>LDAPS/TLS verwenden {{ hi.hint_icon("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.", "LDAPS/TLS verwenden") }}</span>
|
||||||
</label>
|
</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>
|
||||||
<div class="field"><label>Port</label>
|
<div class="field"><label>Port</label>
|
||||||
<input type="number" name="ldap_port" id="ldap_port" min="1" max="65535" value="{{ ldap.port }}">
|
<input type="number" name="ldap_port" id="ldap_port" min="1" max="65535" value="{{ ldap.port }}">
|
||||||
@@ -43,13 +42,11 @@
|
|||||||
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
<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 %}>
|
<input type="checkbox" name="ldap_tls_skip_verify" {% if ldap.tls_skip_verify %}checked{% endif %}>
|
||||||
<span class="track"></span>
|
<span class="track"></span>
|
||||||
<span>Zertifikatsprüfung überspringen (nur LDAPS)</span>
|
<span>Zertifikatsprüfung überspringen (nur LDAPS) {{ hi.hint_icon("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.", "Zertifikatsprüfung überspringen") }}</span>
|
||||||
</label>
|
</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>
|
||||||
<div class="field"><label>Bind-Konto (Service-Account)</label>
|
<div class="field"><label>Bind-Konto (Service-Account) {{ hi.hint_icon("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.", "Bind-Konto (Service-Account)") }}</label>
|
||||||
<input type="text" name="ldap_bind_dn" value="{{ ldap.bind_dn }}" placeholder="z.B. ldap@ad.firma.local">
|
<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>
|
||||||
<div class="field"><label>Bind-Passwort</label>
|
<div class="field"><label>Bind-Passwort</label>
|
||||||
<input type="password" name="ldap_bind_password" placeholder="{{ '(unverändert lassen)' if ldap.bind_password_enc else '' }}">
|
<input type="password" name="ldap_bind_password" placeholder="{{ '(unverändert lassen)' if ldap.bind_password_enc else '' }}">
|
||||||
@@ -57,18 +54,16 @@
|
|||||||
<div class="field"><label>Base-DN</label>
|
<div class="field"><label>Base-DN</label>
|
||||||
<input type="text" name="ldap_base_dn" value="{{ ldap.base_dn }}" placeholder="Leer = automatisch ermitteln">
|
<input type="text" name="ldap_base_dn" value="{{ ldap.base_dn }}" placeholder="Leer = automatisch ermitteln">
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Attribut für Benutzername</label>
|
<div class="field"><label>Attribut für Benutzername {{ hi.hint_icon("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.", "Attribut für Benutzername") }}</label>
|
||||||
<input type="text" name="ldap_user_filter_attr" value="{{ ldap.filter_attr }}" placeholder="sAMAccountName">
|
<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>
|
||||||
<div class="field"><label>Standardgruppe für neue AD-Benutzer</label>
|
<div class="field"><label>Standardgruppe für neue AD-Benutzer {{ hi.hint_icon("Wird nur zugewiesen, wenn unten keine AD-Gruppenzuordnung greift — siehe Karte „AD-Gruppenzuordnungen“.", "Standardgruppe für neue AD-Benutzer") }}</label>
|
||||||
<select name="ldap_default_group">
|
<select name="ldap_default_group">
|
||||||
<option value="">Systemstandard ({{ ldap_groups|selectattr('is_default')|map(attribute='name')|first or 'Benutzer' }})</option>
|
<option value="">Systemstandard ({{ ldap_groups|selectattr('is_default')|map(attribute='name')|first or 'Benutzer' }})</option>
|
||||||
{% for g in ldap_groups %}
|
{% for g in ldap_groups %}
|
||||||
<option value="{{ g['id'] }}" {% if ldap.default_group == g['id']|string %}selected{% endif %}>{{ g['name'] }}</option>
|
<option value="{{ g['id'] }}" {% if ldap.default_group == g['id']|string %}selected{% endif %}>{{ g['name'] }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<div class="field-hint">Wird nur zugewiesen, wenn unten keine AD-Gruppenzuordnung greift — siehe Karte „AD-Gruppenzuordnungen“.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Erforderliche AD-Gruppe für Login (optional)</label>
|
<div class="field"><label>Erforderliche AD-Gruppe für Login (optional)</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -108,12 +103,7 @@
|
|||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
<div class="section-head" style="margin-bottom:16px;">
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="font-size:16px;">AD-Gruppenzuordnungen</h2>
|
<h2 style="font-size:16px;">AD-Gruppenzuordnungen {{ hi.hint_icon("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.", "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>
|
</div>
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<button type="button" class="btn btn-primary" data-open-modal="addLdapMappingModal">
|
<button type="button" class="btn btn-primary" data-open-modal="addLdapMappingModal">
|
||||||
@@ -163,13 +153,7 @@
|
|||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
<div class="section-head" style="margin-bottom:16px;">
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="font-size:16px;">Fileshare-Gruppen</h2>
|
<h2 style="font-size:16px;">Fileshare-Gruppenzuordnung {{ hi.hint_icon("Ist ein AD-Benutzer (rekursiv) Mitglied einer hier zugeordneten AD-Gruppe, wird die zugehörige Freigabe beim Login für ihn gemountet — sofern er zusätzlich das TESM-Recht „Dateifreigaben lesen“ hat (siehe Gruppen → Rechte, Bereich „Dateifreigaben“). Fehlt das Recht, wird nicht gemountet und der Menüpunkt „Dateifreigaben“ erscheint nicht, unabhängig von der AD-Gruppenmitgliedschaft.", "Fileshare-Gruppenzuordnung") }}</h2>
|
||||||
<div class="hint">
|
|
||||||
Ist ein AD-Benutzer (rekursiv) Mitglied einer hier zugeordneten AD-Gruppe, wird die zugehörige
|
|
||||||
Freigabe beim Login für ihn gemountet — sofern er zusätzlich das TESM-Recht „Dateifreigaben lesen“
|
|
||||||
hat (siehe Gruppen → Rechte, Bereich „Dateifreigaben“). Fehlt das Recht, wird nicht gemountet und
|
|
||||||
der Menüpunkt „Dateifreigaben“ erscheint nicht, unabhängig von der AD-Gruppenmitgliedschaft.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<button type="button" class="btn btn-primary" data-open-modal="addFileshareMappingModal">
|
<button type="button" class="btn btn-primary" data-open-modal="addFileshareMappingModal">
|
||||||
@@ -278,13 +262,11 @@
|
|||||||
<input type="hidden" name="fs_ad_group_name" id="fsMappingAdGroupName">
|
<input type="hidden" name="fs_ad_group_name" id="fsMappingAdGroupName">
|
||||||
<div class="field-hint" id="fsMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
|
<div class="field-hint" id="fsMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Bezeichnung</label>
|
<div class="field"><label>Bezeichnung {{ hi.hint_icon("Anzeigename in der Freigaben-Auswahl — auch Ordnername unter dem Mount-Punkt.", "Bezeichnung") }}</label>
|
||||||
<input type="text" name="fs_share_label" placeholder="z.B. Vertrieb" required>
|
<input type="text" name="fs_share_label" placeholder="z.B. Vertrieb" required>
|
||||||
<div class="field-hint">Anzeigename in der Freigaben-Auswahl — auch Ordnername unter dem Mount-Punkt.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Freigabe-Pfad (UNC)</label>
|
<div class="field"><label>Freigabe-Pfad (UNC) {{ hi.hint_icon("Beide Schreibweisen funktionieren — \\\\server\\freigabe wird automatisch in das von Linux benötigte //server/freigabe umgewandelt.", "Freigabe-Pfad (UNC)") }}</label>
|
||||||
<input type="text" name="fs_share_unc" placeholder="//fileserver/freigabe" required>
|
<input type="text" name="fs_share_unc" placeholder="//fileserver/freigabe" required>
|
||||||
<div class="field-hint">Beide Schreibweisen funktionieren — <code>\\server\freigabe</code> wird automatisch in das von Linux benötigte <code>//server/freigabe</code> umgewandelt.</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -349,13 +331,11 @@
|
|||||||
<input type="hidden" name="fs_ad_group_name" id="editFsMappingAdGroupName">
|
<input type="hidden" name="fs_ad_group_name" id="editFsMappingAdGroupName">
|
||||||
<div class="field-hint" id="editFsMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
|
<div class="field-hint" id="editFsMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Bezeichnung</label>
|
<div class="field"><label>Bezeichnung {{ hi.hint_icon("Anzeigename in der Freigaben-Auswahl — auch Ordnername unter dem Mount-Punkt.", "Bezeichnung") }}</label>
|
||||||
<input type="text" name="fs_share_label" id="editFsMappingLabel" placeholder="z.B. Vertrieb" required>
|
<input type="text" name="fs_share_label" id="editFsMappingLabel" placeholder="z.B. Vertrieb" required>
|
||||||
<div class="field-hint">Anzeigename in der Freigaben-Auswahl — auch Ordnername unter dem Mount-Punkt.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Freigabe-Pfad (UNC)</label>
|
<div class="field"><label>Freigabe-Pfad (UNC) {{ hi.hint_icon("Beide Schreibweisen funktionieren — \\\\server\\freigabe wird automatisch in das von Linux benötigte //server/freigabe umgewandelt.", "Freigabe-Pfad (UNC)") }}</label>
|
||||||
<input type="text" name="fs_share_unc" id="editFsMappingUnc" placeholder="//fileserver/freigabe" required>
|
<input type="text" name="fs_share_unc" id="editFsMappingUnc" placeholder="//fileserver/freigabe" required>
|
||||||
<div class="field-hint">Beide Schreibweisen funktionieren — <code>\\server\freigabe</code> wird automatisch in das von Linux benötigte <code>//server/freigabe</code> umgewandelt.</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">Reverse-Proxy: Domain, Ports, SSL/HSTS und Zertifikat</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">Reverse-Proxy: Domain, Ports, SSL/HSTS und Zertifikat</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
<div class="settings-grid settings-grid--wide">
|
<div class="settings-grid settings-grid--wide">
|
||||||
|
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
@@ -38,9 +39,8 @@
|
|||||||
<form method="post"
|
<form method="post"
|
||||||
data-confirm="nginx-Konfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ nginx_revert_seconds }} Sekunden wiederhergestellt.">
|
data-confirm="nginx-Konfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ nginx_revert_seconds }} Sekunden wiederhergestellt.">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="server_name">Domain (server_name)</label>
|
<label for="server_name">Domain (server_name) {{ hi.hint_icon("\"_\" ist nginx' Catch-all (Standard für interne Instanzen ohne eigene Domain) — für Let's Encrypt muss hier die tatsächliche, öffentlich auflösbare Domain stehen.", "Domain (server_name)") }}</label>
|
||||||
<input type="text" name="server_name" id="server_name" value="{{ server_name }}" placeholder="_ (kein bestimmter Hostname) oder z.B. tesm.example.com">
|
<input type="text" name="server_name" id="server_name" value="{{ server_name }}" placeholder="_ (kein bestimmter Hostname) oder z.B. tesm.example.com">
|
||||||
<div class="field-hint">"_" ist nginx' Catch-all (Standard für interne Instanzen ohne eigene Domain) — für Let's Encrypt muss hier die tatsächliche, öffentlich auflösbare Domain stehen.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<div class="field field--half">
|
<div class="field field--half">
|
||||||
@@ -48,14 +48,10 @@
|
|||||||
<input type="number" name="http_port" id="http_port" min="1" max="65535" value="{{ http_port }}" required>
|
<input type="number" name="http_port" id="http_port" min="1" max="65535" value="{{ http_port }}" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="field field--half">
|
<div class="field field--half">
|
||||||
<label for="https_port">HTTPS-Port</label>
|
<label for="https_port">HTTPS-Port {{ hi.hint_icon("Let's Encrypt validiert IMMER über Port 80 (protokollbedingt, unabhängig vom hier eingestellten HTTP-Port) — weicht der HTTP-Port von 80 ab, wird dafür automatisch zusätzlich ein minimaler Port-80-Block mitgeschrieben.", "HTTP-Port / HTTPS-Port") }}</label>
|
||||||
<input type="number" name="https_port" id="https_port" min="1" max="65535" value="{{ https_port }}" required>
|
<input type="number" name="https_port" id="https_port" min="1" max="65535" value="{{ https_port }}" required>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-hint" style="margin-top:-8px; margin-bottom:14px;">
|
|
||||||
Let's Encrypt validiert IMMER über Port 80 (protokollbedingt, unabhängig vom hier eingestellten HTTP-Port) —
|
|
||||||
weicht der HTTP-Port von 80 ab, wird dafür automatisch zusätzlich ein minimaler Port-80-Block mitgeschrieben.
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
||||||
<input type="checkbox" name="ssl_enabled" id="ssl_enabled_check" {% if ssl_enabled %}checked{% endif %} {% if not cert_info %}disabled{% endif %}>
|
<input type="checkbox" name="ssl_enabled" id="ssl_enabled_check" {% if ssl_enabled %}checked{% endif %} {% if not cert_info %}disabled{% endif %}>
|
||||||
@@ -68,9 +64,8 @@
|
|||||||
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
||||||
<input type="checkbox" name="hsts_enabled" id="hsts_enabled_check" {% if hsts_enabled %}checked{% endif %} {% if not ssl_enabled %}disabled{% endif %}>
|
<input type="checkbox" name="hsts_enabled" id="hsts_enabled_check" {% if hsts_enabled %}checked{% endif %} {% if not ssl_enabled %}disabled{% endif %}>
|
||||||
<span class="track"></span>
|
<span class="track"></span>
|
||||||
<span>HSTS (Strict-Transport-Security)</span>
|
<span>HSTS (Strict-Transport-Security) {{ hi.hint_icon("Weist Browser an, diese Instanz künftig NUR noch über HTTPS aufzurufen — bleibt auch bei einem späteren Zurückschalten auf HTTP im Browser für 1 Jahr bestehen. Nur aktivieren, wenn das Zertifikat dauerhaft gepflegt wird.", "HSTS") }}</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="field-hint">Weist Browser an, diese Instanz künftig NUR noch über HTTPS aufzurufen — bleibt auch bei einem späteren Zurückschalten auf HTTP im Browser für 1 Jahr bestehen. Nur aktivieren, wenn das Zertifikat dauerhaft gepflegt wird.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" name="apply_nginx" value="1" class="btn btn-primary btn-block">
|
<button type="submit" name="apply_nginx" 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="M9 12l2 2 4-4"/><path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 12l2 2 4-4"/><path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/></svg>
|
||||||
@@ -165,9 +160,8 @@
|
|||||||
<input type="file" name="cert_file" id="cert_file" accept=".pem,.crt,.cer" required>
|
<input type="file" name="cert_file" id="cert_file" accept=".pem,.crt,.cer" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="key_file">Privater Schlüssel (PEM, unverschlüsselt)</label>
|
<label for="key_file">Privater Schlüssel (PEM, unverschlüsselt) {{ hi.hint_icon("Ein passwortgeschütztes Schlüssel wird abgelehnt — nginx könnte ihn beim Start ohnehin nicht ohne manuelle Passworteingabe laden.", "Privater Schlüssel") }}</label>
|
||||||
<input type="file" name="key_file" id="key_file" accept=".pem,.key" required>
|
<input type="file" name="key_file" id="key_file" accept=".pem,.key" required>
|
||||||
<div class="field-hint">Ein passwortgeschütztes Schlüssel wird abgelehnt — nginx könnte ihn beim Start ohnehin nicht ohne manuelle Passworteingabe laden.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" name="upload_cert" value="1" class="btn btn-secondary btn-block">
|
<button type="submit" name="upload_cert" value="1" 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="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
|
<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="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">{{ switches|length }} Switche</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">{{ switches|length }} Switche</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
@@ -102,9 +103,8 @@
|
|||||||
<input type="text" name="ip" value="{{ s['ip'] }}" required placeholder="z.B. 192.168.1.100">
|
<input type="text" name="ip" value="{{ s['ip'] }}" required placeholder="z.B. 192.168.1.100">
|
||||||
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>SSH-Port</label>
|
<div class="field"><label>SSH-Port {{ hi.hint_icon("Leer lassen, wenn der Switch den Standard-Port 22 verwendet.", "SSH-Port") }}</label>
|
||||||
<input type="number" name="ssh_port" value="{{ s['ssh_port'] or '' }}" min="1" max="65535" placeholder="22 (Standard)">
|
<input type="number" name="ssh_port" value="{{ s['ssh_port'] or '' }}" min="1" max="65535" placeholder="22 (Standard)">
|
||||||
<div class="field-hint">Leer lassen, wenn der Switch den Standard-Port 22 verwendet.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Zugangsdaten</label>
|
<label>Zugangsdaten</label>
|
||||||
@@ -162,9 +162,8 @@
|
|||||||
<input type="text" name="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 class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>SSH-Port</label>
|
<div class="field"><label>SSH-Port {{ hi.hint_icon("Leer lassen, wenn der Switch den Standard-Port 22 verwendet.", "SSH-Port") }}</label>
|
||||||
<input type="number" name="ssh_port" min="1" max="65535" placeholder="22 (Standard)">
|
<input type="number" name="ssh_port" min="1" max="65535" placeholder="22 (Standard)">
|
||||||
<div class="field-hint">Leer lassen, wenn der Switch den Standard-Port 22 verwendet.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Zugangsdaten</label>
|
<label>Zugangsdaten</label>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">{{ users|length }} Benutzer</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">{{ users|length }} Benutzer</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% import "_hint_icon.html" as hi %}
|
||||||
|
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
@@ -205,13 +206,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Gruppe</label>
|
<label>Gruppe {{ hi.hint_icon("Ersetzt die bisherige Gruppen-/Rollenzuordnung dieses Benutzers.", "Gruppe") }}</label>
|
||||||
<select name="group_id" id="group_select">
|
<select name="group_id" id="group_select">
|
||||||
<option value="">Keine Gruppe</option>
|
<option value="">Keine Gruppe</option>
|
||||||
{% for g in all_groups %}<option value="{{ g['id'] }}">{{ g['name'] }}</option>{% endfor %}
|
{% for g in all_groups %}<option value="{{ g['id'] }}">{{ g['name'] }}</option>{% endfor %}
|
||||||
{% if current_user.is_admin %}<option value="admin">Admin</option>{% endif %}
|
{% if current_user.is_admin %}<option value="admin">Admin</option>{% endif %}
|
||||||
</select>
|
</select>
|
||||||
<div class="field-hint">Ersetzt die bisherige Gruppen-/Rollenzuordnung dieses Benutzers.</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
|||||||
Reference in New Issue
Block a user