ui rehaul
This commit is contained in:
+52
-8
@@ -13,8 +13,10 @@ import uuid
|
||||
|
||||
try:
|
||||
from . import reconcile_shares as directory
|
||||
from .account_policy import is_excluded_user
|
||||
except ImportError:
|
||||
import reconcile_shares as directory
|
||||
from account_policy import is_excluded_user
|
||||
|
||||
SID_RE = re.compile(r"S-1-5-21-\d+-\d+-\d+-\d+\Z")
|
||||
READ = 0x1200A9
|
||||
@@ -146,12 +148,14 @@ def migrate(conn):
|
||||
seen = {*seen, group["dn"]}
|
||||
rid = directory.sid_rid(group["sid"])
|
||||
result = {sid for sid, user in users.items()
|
||||
if user["primaryRid"] == rid and sid.rsplit("-", 1)[0] == group["sid"].rsplit("-", 1)[0]}
|
||||
if not is_excluded_user(user["sam"]) and user["primaryRid"] == rid and sid.rsplit("-", 1)[0] == group["sid"].rsplit("-", 1)[0]}
|
||||
for dn in group["members"]:
|
||||
if dn in groups:
|
||||
result.update(members(groups[dn], seen))
|
||||
elif dn in by_dn:
|
||||
result.add(by_dn[dn])
|
||||
sid = by_dn[dn]
|
||||
if not is_excluded_user(users[sid]["sam"]):
|
||||
result.add(sid)
|
||||
else:
|
||||
raise RuntimeError(f"Cannot import unresolved member {dn}; migration will retry")
|
||||
return result
|
||||
@@ -297,22 +301,33 @@ def create_folder_record(conn, name, path):
|
||||
return folder_id
|
||||
|
||||
|
||||
def excluded_user_sids(conn):
|
||||
# Retain identities internally to recognize old assignments and resolve LDAP
|
||||
# memberships, including accounts no longer returned by the directory.
|
||||
return {row["sid"] for row in conn.execute("SELECT sid,sam FROM access_users")
|
||||
if is_excluded_user(row["sam"])}
|
||||
|
||||
|
||||
def snapshot(conn, users=None):
|
||||
user_rows = [dict(row) for row in conn.execute("SELECT * FROM access_users ORDER BY sam COLLATE NOCASE")]
|
||||
excluded = excluded_user_sids(conn)
|
||||
user_rows = [dict(row) for row in conn.execute("SELECT * FROM access_users ORDER BY sam COLLATE NOCASE")
|
||||
if row["sid"] not in excluded]
|
||||
if users is not None:
|
||||
for user in user_rows:
|
||||
user["available"] = user["sid"] in users
|
||||
folders = []
|
||||
for row in conn.execute("SELECT * FROM shares ORDER BY shareName COLLATE NOCASE"):
|
||||
folders.append({"id": row["objectGUID"], "name": row["shareName"], "active": bool(row["isActive"]),
|
||||
"permissions": [dict(p) for p in conn.execute("SELECT kind,principalId,level FROM folder_permissions WHERE folderId=? ORDER BY kind,principalId", (row["objectGUID"],))]})
|
||||
"permissions": [dict(p) for p in conn.execute("SELECT kind,principalId,level FROM folder_permissions WHERE folderId=? ORDER BY kind,principalId", (row["objectGUID"],)) if p["principalId"] not in excluded]})
|
||||
return {"users": user_rows, "folders": folders,
|
||||
"fetchedAt": timestamp(), "initialized": initialized(conn)}
|
||||
|
||||
|
||||
def effective_levels(conn, folder_id):
|
||||
excluded = excluded_user_sids(conn)
|
||||
return {row["principalId"]: row["level"] for row in conn.execute(
|
||||
"SELECT principalId,level FROM folder_permissions WHERE folderId=?", (folder_id,))}
|
||||
"SELECT principalId,level FROM folder_permissions WHERE folderId=?", (folder_id,))
|
||||
if row["principalId"] not in excluded}
|
||||
|
||||
|
||||
def descriptor(levels, admin_sid, is_dir=True, top_level=False):
|
||||
@@ -411,7 +426,35 @@ def recover_pending(conn):
|
||||
directory.log("Recovered interrupted admin access update")
|
||||
|
||||
|
||||
def revoke_excluded_permissions(conn):
|
||||
excluded = excluded_user_sids(conn)
|
||||
rules = [dict(row) for row in conn.execute("SELECT * FROM folder_permissions")
|
||||
if row["principalId"] in excluded]
|
||||
if not rules:
|
||||
return
|
||||
for rule in rules:
|
||||
conn.execute("DELETE FROM folder_permissions WHERE folderId=? AND principalId=?",
|
||||
(rule["folderId"], rule["principalId"]))
|
||||
conn.execute("UPDATE shares SET aclSignature='' WHERE objectGUID=?", (rule["folderId"],))
|
||||
if any(rule["level"] for rule in rules):
|
||||
conn.execute("INSERT OR REPLACE INTO access_settings VALUES('pendingExcludedRevocation','1')")
|
||||
conn.execute("INSERT INTO access_changes(timestamp,actor,action,details) VALUES(?,?,?,?)",
|
||||
(timestamp(), "system", "revoke-excluded-users", json.dumps(rules)))
|
||||
|
||||
|
||||
def close_data_connections(conn, force=False):
|
||||
pending = conn.execute("SELECT 1 FROM access_settings WHERE key='pendingExcludedRevocation'").fetchone()
|
||||
if not force and not pending:
|
||||
return
|
||||
result = directory.run_command(["smbcontrol", "all", "close-share", "Data"], check=False)
|
||||
if result.returncode:
|
||||
raise RuntimeError("Berechtigungen gespeichert; SMB-Verbindungen konnten nicht geschlossen werden. Samba neu starten.")
|
||||
conn.execute("DELETE FROM access_settings WHERE key='pendingExcludedRevocation'")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def sync_permissions(conn, force=False):
|
||||
revoke_excluded_permissions(conn)
|
||||
admin_sid = os.getenv("DOMAIN_ADMINS_SID", "")
|
||||
root = directory.GROUP_ROOT
|
||||
os.makedirs(root, exist_ok=True)
|
||||
@@ -461,6 +504,7 @@ def apply_change(conn, body, actor, users):
|
||||
cache_users(conn, users)
|
||||
conn.commit()
|
||||
recover_pending(conn)
|
||||
excluded = excluded_user_sids(conn)
|
||||
pending = {}
|
||||
if action == "create-folder":
|
||||
pending["create"] = os.path.join(directory.GROUP_ROOT, valid_name(body.get("name")))
|
||||
@@ -509,6 +553,8 @@ def apply_change(conn, body, actor, users):
|
||||
seen.add((kind, principal))
|
||||
if principal not in users and principal not in old_users:
|
||||
raise ValueError("Unbekannter AD-Benutzer")
|
||||
if level and principal in excluded:
|
||||
raise ValueError("Systemkonto kann keine Ordnerberechtigung erhalten")
|
||||
checked.append((folder_id, kind, principal, level))
|
||||
conn.execute("DELETE FROM folder_permissions WHERE folderId=?", (folder_id,))
|
||||
conn.executemany("INSERT INTO folder_permissions VALUES(?,?,?,?)", checked)
|
||||
@@ -550,9 +596,7 @@ def apply_change(conn, body, actor, users):
|
||||
conn.commit()
|
||||
raise
|
||||
# Existing handles carry cached access masks; disconnect Data clients.
|
||||
result = directory.run_command(["smbcontrol", "all", "close-share", "Data"], check=False)
|
||||
if result.returncode:
|
||||
raise RuntimeError("Berechtigungen gespeichert; SMB-Verbindungen konnten nicht geschlossen werden. Samba neu starten.")
|
||||
close_data_connections(conn, force=True)
|
||||
return snapshot(conn, users)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Account names excluded from human user administration."""
|
||||
|
||||
|
||||
def account_name(user: str) -> str:
|
||||
account = user.strip().rsplit("\\", 1)[-1]
|
||||
return account.split("@", 1)[0]
|
||||
|
||||
|
||||
def is_excluded_user(user: str) -> bool:
|
||||
account = account_name(user).casefold()
|
||||
return account == "krbtgt" or account.startswith("msol_")
|
||||
+5
-4
@@ -6,6 +6,11 @@ import hashlib
|
||||
import os
|
||||
from typing import Mapping, Optional, Tuple
|
||||
|
||||
try:
|
||||
from .account_policy import account_name
|
||||
except ImportError:
|
||||
from account_policy import account_name
|
||||
|
||||
AUDIT_ACTIONS = frozenset({"read", "write", "move", "delete"})
|
||||
|
||||
OPERATION_ACTIONS = {
|
||||
@@ -41,10 +46,6 @@ def skipped_user_suffixes() -> Tuple[str, ...]:
|
||||
if suffix.strip()
|
||||
)
|
||||
|
||||
def account_name(user: str) -> str:
|
||||
account = user.strip().rsplit("\\", 1)[-1]
|
||||
return account.split("@", 1)[0]
|
||||
|
||||
def skip_user(user: str) -> bool:
|
||||
account = account_name(user).casefold()
|
||||
return any(account.endswith(suffix) for suffix in skipped_user_suffixes())
|
||||
|
||||
+3
-1
@@ -16,6 +16,7 @@ try:
|
||||
skipped_user_suffixes,
|
||||
)
|
||||
from .state_db import connect_state_db
|
||||
from .account_policy import is_excluded_user
|
||||
except ImportError:
|
||||
from audit_policy import (
|
||||
account_name,
|
||||
@@ -24,6 +25,7 @@ except ImportError:
|
||||
skipped_user_suffixes,
|
||||
)
|
||||
from state_db import connect_state_db
|
||||
from account_policy import is_excluded_user
|
||||
|
||||
|
||||
AUDIT_SCHEMA = """
|
||||
@@ -927,7 +929,7 @@ def rollup_facets(
|
||||
|
||||
actions = distinct("action")
|
||||
return {
|
||||
"users": distinct("user"),
|
||||
"users": [user for user in distinct("user") if not is_excluded_user(user)],
|
||||
"shares": distinct("share"),
|
||||
"operations": actions,
|
||||
"actions": actions,
|
||||
|
||||
@@ -17,8 +17,10 @@ from typing import Callable, Dict, Iterable, List, Optional, Set, Tuple
|
||||
|
||||
try:
|
||||
from .state_db import STATE_DB_PATH, connect_state_db
|
||||
from .account_policy import is_excluded_user
|
||||
except ImportError:
|
||||
from state_db import STATE_DB_PATH, connect_state_db
|
||||
from account_policy import is_excluded_user
|
||||
|
||||
|
||||
DB_PATH = STATE_DB_PATH
|
||||
@@ -69,7 +71,6 @@ REQUIRED_ENV = ["REALM", "WORKGROUP", "DOMAIN"]
|
||||
ATTR_RE = re.compile(r"^([^:]+)(::?)\s*(.*)$")
|
||||
GROUP_FOLDER_INVALID_RE = re.compile(r"[\\/:*?\"<>|]")
|
||||
PRIVATE_SKIP_EXACT = {
|
||||
"krbtgt",
|
||||
"administrator",
|
||||
"guest",
|
||||
"gast",
|
||||
@@ -78,7 +79,7 @@ PRIVATE_SKIP_EXACT = {
|
||||
"fileshare_serviceacc",
|
||||
"fileshare_serviceaccount",
|
||||
}
|
||||
PRIVATE_SKIP_PREFIXES = ("msol_", "fileshare_service", "aad_")
|
||||
PRIVATE_SKIP_PREFIXES = ("fileshare_service", "aad_")
|
||||
UAC_ACCOUNTDISABLE = 0x0002
|
||||
UAC_LOCKOUT = 0x0010
|
||||
AD_NEVER_EXPIRES_VALUES = {0, 9223372036854775807}
|
||||
@@ -1407,7 +1408,7 @@ def list_domain_users(non_login_users: set) -> List[str]:
|
||||
|
||||
def should_skip_private_user(username: str) -> bool:
|
||||
normalized = username.strip().lower()
|
||||
if not normalized:
|
||||
if not normalized or is_excluded_user(username):
|
||||
return True
|
||||
if normalized in PRIVATE_SKIP_EXACT:
|
||||
return True
|
||||
@@ -1717,10 +1718,15 @@ def with_lock() -> bool:
|
||||
import access_control
|
||||
status_progress(10, "database", "Loading admin-managed folders")
|
||||
access_control.migrate(conn)
|
||||
# Refresh user names only; managed permissions never rediscover AD groups.
|
||||
users, _ = access_control.read_directory()
|
||||
access_control.cache_users(conn, users)
|
||||
conn.commit()
|
||||
access_control.recover_pending(conn)
|
||||
status_progress(32, "data-permissions", "Syncing managed folder permissions")
|
||||
access_control.sync_permissions(conn)
|
||||
conn.commit()
|
||||
access_control.close_data_connections(conn)
|
||||
log("Synced admin-managed Data folder permissions")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
+18
-53
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
const state = { session: null, timer: null, groups: null, storage: null, activity: null };
|
||||
const state = { session: null, timer: null, storage: null, activity: null };
|
||||
const loginView = document.querySelector("#login-view");
|
||||
const appView = document.querySelector("#app-view");
|
||||
const content = document.querySelector("#content");
|
||||
@@ -8,6 +8,13 @@ const nav = document.querySelector("#navigation");
|
||||
const toast = document.querySelector("#toast");
|
||||
|
||||
const esc = value => String(value ?? "").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char]));
|
||||
function actionIcon(action) {
|
||||
const paths = {
|
||||
download: '<path d="M12 3v12m-5-5 5 5 5-5"/><path d="M5 16v4h14v-4"/>',
|
||||
restore: '<path d="M3 11a9 9 0 1 1 2.6 7"/><path d="M3 4v7h7"/>',
|
||||
};
|
||||
return `<svg class="action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${paths[action]}</svg>`;
|
||||
}
|
||||
const bytes = value => {
|
||||
let number = Number(value || 0);
|
||||
const units = ["B", "kB", "MB", "GB", "TB", "PB"];
|
||||
@@ -25,7 +32,6 @@ const utcTime = value => {
|
||||
return `${pad(date.getUTCDate())}.${pad(date.getUTCMonth() + 1)}.${date.getUTCFullYear()} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} UTC`;
|
||||
};
|
||||
const actionLabel = value => ({read: "Lesen", write: "Schreiben", move: "Umbenennen", delete: "Löschen"}[value] || value || "—");
|
||||
const nodeTypeLabel = value => ({group: "Gruppe", user: "Benutzer", computer: "Computer", unknown: "Unbekannt"}[value] || value);
|
||||
const backupStateLabel = value => ({starting: "Startet", running: "Läuft", completed: "Abgeschlossen", failed: "Fehlgeschlagen", waiting: "Wartet", disabled: "Deaktiviert"}[value] || value || "Unbekannt");
|
||||
function backupMessage(data) {
|
||||
const message = String(data.message || "");
|
||||
@@ -103,7 +109,6 @@ function routeFor(path) {
|
||||
if (path.startsWith("/storage/data")) return "storage-data";
|
||||
if (path.startsWith("/storage/users")) return "storage-users";
|
||||
if (path.startsWith("/access")) return "access";
|
||||
if (path.startsWith("/shares")) return "shares";
|
||||
if (path.startsWith("/reconciliation")) return "reconciliation";
|
||||
if (path.startsWith("/activity/fslogix")) return "activity-fslogix";
|
||||
if (path.startsWith("/activity")) return "activity";
|
||||
@@ -119,6 +124,7 @@ async function navigate(path, replace = false) {
|
||||
if (replace && state.currentPath) history.replaceState({}, "", state.currentPath);
|
||||
return;
|
||||
}
|
||||
if (path === "/shares" || path.startsWith("/shares/")) { path = "/access"; replace = true; }
|
||||
state.accessDirty = false;
|
||||
state.currentPath = path;
|
||||
clearInterval(state.timer);
|
||||
@@ -130,7 +136,6 @@ async function navigate(path, replace = false) {
|
||||
setLoading();
|
||||
try {
|
||||
if (route === "overview") await renderOverview();
|
||||
if (route === "shares") await renderShares();
|
||||
if (route === "access") await renderAccess();
|
||||
if (route === "reconciliation") await renderReconciliation();
|
||||
if (route === "storage-data") await renderStorage("data");
|
||||
@@ -193,46 +198,6 @@ function usageTable(rows, type) {
|
||||
}).join("")}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
function nodeMatches(node, query) {
|
||||
if (!query) return true;
|
||||
if (`${node.name} ${node.sam} ${node.type} ${nodeTypeLabel(node.type)}`.toLowerCase().includes(query)) return true;
|
||||
return (node.members || []).some(child => nodeMatches(child, query));
|
||||
}
|
||||
|
||||
function treeNodes(nodes, query = "") {
|
||||
return nodes.filter(node => nodeMatches(node, query)).map(node => {
|
||||
const children = treeNodes(node.members || [], query);
|
||||
const title = `<span class="kind">${esc(nodeTypeLabel(node.type))}</span> <strong>${esc(node.name)}</strong>${node.sam && node.sam !== node.name ? ` <span class="muted">${esc(node.sam)}</span>` : ""}${node.cycle ? ` ${badge("Zyklus", "warn")}` : ""}`;
|
||||
return children ? `<details ${query ? "open" : ""}><summary>${title}</summary>${children}</details>` : `<div class="leaf">${title}</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
async function renderShares() {
|
||||
const data = await api("/api/groups");
|
||||
state.groups = data;
|
||||
const groups = data.groups || [];
|
||||
content.innerHTML = `<div class="shares-view">` + pageHead("Dateifreigaben", "Verwaltete Ordner und ihre individuellen Benutzerberechtigungen.", `<span class="muted">Verzeichnisstand ${esc(utcTime(data.fetchedAt))}</span>`) + `
|
||||
${data.truncated ? `<p>${badge("Ergebnis gekürzt", "warn")} Mitgliedschaftsliste ist unvollständig.</p>` : ""}
|
||||
<section class="split shares-split">
|
||||
<article class="panel"><div class="panel-head"><h2>Datenordner</h2><span>${groups.length}</span></div><input id="group-filter" type="search" placeholder="Ordner oder Benutzer filtern"><ul id="group-list" class="list"></ul></article>
|
||||
<article class="panel"><div id="tree-panel"></div></article>
|
||||
</section></div>`;
|
||||
const list = document.querySelector("#group-list");
|
||||
const tree = document.querySelector("#tree-panel");
|
||||
let selected = groups[0] || null;
|
||||
let query = "";
|
||||
const draw = () => {
|
||||
const visible = groups.filter(group => `${group.name} ${group.sam} ${group.folder}`.toLowerCase().includes(query) || group.members.some(node => nodeMatches(node, query)));
|
||||
if (selected && !visible.includes(selected)) selected = visible[0] || null;
|
||||
list.innerHTML = visible.length ? visible.map(group => `<li><button class="select-row ${group === selected ? "active" : ""}" data-guid="${esc(group.guid)}"><span><strong>${esc(group.folder)}</strong><br><span class="muted">${esc(group.sam)}</span></span><span>${group.userCount} Benutzer</span></button></li>`).join("") : empty("Kein Ordner entspricht dem Filter.");
|
||||
if (!selected) tree.innerHTML = empty("Datenordner auswählen.");
|
||||
else tree.innerHTML = `<div class="panel-head"><div><h2>${esc(selected.folder)}</h2><span class="muted">${esc(selected.sam)} · ${selected.userCount} wirksame Benutzer</span></div>${selected.active ? badge("Aktiv") : badge("Archiviert", "warn")}</div><div class="tree">${treeNodes(selected.members, query) || empty("Keine Benutzer mit Zugriff")}</div>`;
|
||||
list.querySelectorAll("button[data-guid]").forEach(button => button.addEventListener("click", () => { selected = groups.find(group => group.guid === button.dataset.guid); draw(); }));
|
||||
};
|
||||
document.querySelector("#group-filter").addEventListener("input", event => { query = event.target.value.trim().toLowerCase(); draw(); });
|
||||
draw();
|
||||
}
|
||||
|
||||
const permissionLabels = ["Kein Zugriff", "Lesen", "Lesen + Ändern", "Lesen + Ändern + Löschen"];
|
||||
|
||||
async function renderAccess() {
|
||||
@@ -284,7 +249,7 @@ async function renderAccess() {
|
||||
try {
|
||||
data = await api("/api/access", {method: "POST", body: JSON.stringify(body)});
|
||||
if (body.action === "create-folder") {
|
||||
folderFilter = "active"; query = ""; userQuery = ""; userFilter = "all";
|
||||
folderFilter = "active"; query = "";
|
||||
folderId = data.folders.find(item => item.name === body.name.trim())?.id;
|
||||
}
|
||||
if (body.action === "archive-folder") folderFilter = "archived";
|
||||
@@ -310,11 +275,11 @@ async function renderAccess() {
|
||||
}
|
||||
}
|
||||
|
||||
function dialog(title, explanation, label, action, initial = null, danger = false) {
|
||||
function dialog(title, explanation, label, action, initial = null, danger = false, icon = null) {
|
||||
const modal = document.createElement("dialog");
|
||||
modal.className = "access-dialog";
|
||||
modal.setAttribute("aria-label", title);
|
||||
modal.innerHTML = `<form><h2>${esc(title)}</h2><p>${esc(explanation)}</p>${initial !== null ? `<label>Name<input value="${esc(initial)}" maxlength="120" required autofocus></label>` : ""}<div class="access-dialog-actions"><button type="button" data-cancel>Abbrechen</button><button type="submit" class="${danger ? "access-danger" : ""}">${esc(label)}</button></div></form>`;
|
||||
modal.innerHTML = `<form><h2>${esc(title)}</h2><p>${esc(explanation)}</p>${initial !== null ? `<label>Name<input value="${esc(initial)}" maxlength="120" required autofocus></label>` : ""}<div class="access-dialog-actions"><button type="button" data-cancel>Abbrechen</button><button type="submit" class="${danger ? "access-danger" : ""} ${icon ? "icon-button" : ""}"${icon ? ` aria-label="${esc(label)}" title="${esc(label)}"` : ""}>${icon ? actionIcon(icon) : esc(label)}</button></div></form>`;
|
||||
document.body.append(modal);
|
||||
modal.querySelector("[data-cancel]").addEventListener("click", () => modal.close());
|
||||
modal.addEventListener("close", () => modal.remove());
|
||||
@@ -335,11 +300,11 @@ async function renderAccess() {
|
||||
root.querySelector("#access-list").innerHTML = items.length ? items.map(item => {
|
||||
const count = item.permissions.filter(rule => rule.level > 0).length;
|
||||
const subtitle = item.active ? `${count} Benutzer` : "Archiviert";
|
||||
return `<button type="button" class="select-row access-list-item ${item.id === folderId ? "active" : ""}" data-select="${esc(item.id)}" aria-pressed="${item.id === folderId}"><strong>${esc(item.name)}</strong><small>${esc(subtitle)}</small></button>`;
|
||||
return `<button type="button" class="select-row access-list-item ${item.id === folderId ? "active" : ""}" data-select="${esc(item.id)}" aria-pressed="${item.id === folderId}"><strong title="${esc(item.name)}">${esc(item.name)}</strong><small>${esc(subtitle)}</small></button>`;
|
||||
}).join("") : `<div class="access-empty">${query ? "Keine Treffer. Suchbegriff ändern." : "Keine Ordner in dieser Ansicht."}</div>`;
|
||||
root.querySelectorAll("[data-select]").forEach(button => button.addEventListener("click", () => {
|
||||
if (button.dataset.select === folderId || !canLeave()) return;
|
||||
folderId = button.dataset.select; userQuery = ""; userFilter = "all";
|
||||
folderId = button.dataset.select;
|
||||
loadDraft(); drawList(); drawDetail();
|
||||
}));
|
||||
}
|
||||
@@ -374,7 +339,7 @@ async function renderAccess() {
|
||||
detail.innerHTML = data.folders.length ? '<div class="access-empty"><h2 id="access-folder-title">Kein Ordner in dieser Ansicht</h2><p>Den Ordnerstatus wechseln oder einen neuen Ordner anlegen.</p></div>' : '<div class="access-empty"><h2 id="access-folder-title">Ersten Ordner anlegen</h2><p>Mit „Neuer Ordner“ beginnen. Anschließend die Rechte für einzelne AD-Benutzer festlegen.</p></div>';
|
||||
return;
|
||||
}
|
||||
detail.innerHTML = `<header class="access-detail-head"><div><h2 id="access-folder-title">${esc(selected.name)}</h2><p>${selected.active ? "Zugriff für jeden AD-Benutzer einzeln festlegen. Ohne Zuweisung kein Zugriff." : "Dieser Ordner ist archiviert und für Benutzer nicht erreichbar. Gespeicherte Rechte gelten nach dem Wiederherstellen."}</p></div><button type="button" id="access-archive" class="link-button">${selected.active ? "Archivieren" : "Wiederherstellen"}</button></header>
|
||||
detail.innerHTML = `<header class="access-detail-head"><div><h2 id="access-folder-title">${esc(selected.name)}</h2><p>${selected.active ? "Zugriff für jeden AD-Benutzer einzeln festlegen. Ohne Zuweisung kein Zugriff." : "Dieser Ordner ist archiviert und für Benutzer nicht erreichbar. Gespeicherte Rechte gelten nach dem Wiederherstellen."}</p></div><button type="button" id="access-archive" class="${selected.active ? "link-button" : "icon-button"}"${selected.active ? "" : ' aria-label="Wiederherstellen" title="Wiederherstellen"'}>${selected.active ? "Archivieren" : actionIcon("restore")}</button></header>
|
||||
<section class="access-section"><div class="access-section-head"><h3>Benutzerrechte</h3></div>
|
||||
<div class="access-filter-row"><label><span class="sr-only">Benutzer suchen</span><input id="access-user-search" type="search" placeholder="Name oder Benutzername suchen" value="${esc(userQuery)}"></label><label><span class="sr-only">Zugriff filtern</span><select id="access-user-filter"><option value="all">Alle Benutzer</option><option value="granted">${selected.active ? "Mit Zugriff" : "Mit gespeicherten Rechten"}</option><option value="hidden">${selected.active ? "Ohne Zugriff" : "Ohne gespeicherte Rechte"}</option></select></label></div>
|
||||
<p id="access-user-count" class="muted" role="status"></p>
|
||||
@@ -388,7 +353,7 @@ async function renderAccess() {
|
||||
detail.querySelector("#access-discard").addEventListener("click", () => { loadDraft(); drawDetail(); });
|
||||
detail.querySelector("#access-archive").addEventListener("click", () => {
|
||||
if (!canLeave()) return;
|
||||
dialog(selected.active ? "Ordner archivieren?" : "Ordner wiederherstellen?", selected.active ? `„${selected.name}“ wird für Benutzer ausgeblendet. Alle Dateien bleiben erhalten.` : `„${selected.name}“ wird mit den gespeicherten Rechten wieder freigegeben.`, selected.active ? "Archivieren" : "Wiederherstellen", () => save({action: selected.active ? "archive-folder" : "restore-folder", id: selected.id}), null, selected.active);
|
||||
dialog(selected.active ? "Ordner archivieren?" : "Ordner wiederherstellen?", selected.active ? `„${selected.name}“ wird für Benutzer ausgeblendet. Alle Dateien bleiben erhalten.` : `„${selected.name}“ wird mit den gespeicherten Rechten wieder freigegeben.`, selected.active ? "Archivieren" : "Wiederherstellen", () => save({action: selected.active ? "archive-folder" : "restore-folder", id: selected.id}), null, selected.active, selected.active ? null : "restore");
|
||||
});
|
||||
drawUsers(); updateDirty();
|
||||
}
|
||||
@@ -403,7 +368,7 @@ async function renderAccess() {
|
||||
if (!canLeave()) { event.target.value = folderFilter; return; }
|
||||
folderFilter = event.target.value;
|
||||
const visible = data.folders.filter(item => folderFilter === "all" || item.active === (folderFilter === "active"));
|
||||
if (!visible.some(item => item.id === folderId)) { folderId = visible[0]?.id; userQuery = ""; userFilter = "all"; }
|
||||
if (!visible.some(item => item.id === folderId)) folderId = visible[0]?.id;
|
||||
loadDraft(); drawList(); drawDetail();
|
||||
});
|
||||
root.querySelector("#access-create").addEventListener("click", () => {
|
||||
@@ -524,7 +489,7 @@ async function renderTrash() {
|
||||
<td class="path">${esc(item.path)}</td>
|
||||
<td class="numeric">${bytes(item.size)}</td>
|
||||
<td class="timestamp">${esc(utcTime(item.expiresAt))}</td>
|
||||
<td><div class="row-actions"><a class="button" href="/api/trash/download?id=${encodeURIComponent(item.id)}" download>Herunterladen</a><button type="button" data-restore="${esc(item.id)}">Wiederherstellen</button></div></td>
|
||||
<td><div class="row-actions"><a class="button icon-button" href="/api/trash/download?id=${encodeURIComponent(item.id)}" download aria-label="Herunterladen" title="Herunterladen">${actionIcon("download")}</a><button type="button" class="icon-button" data-restore="${esc(item.id)}" aria-label="Wiederherstellen" title="Wiederherstellen">${actionIcon("restore")}</button></div></td>
|
||||
</tr>`).join("");
|
||||
}
|
||||
const shown = result.items.length.toLocaleString("de-DE");
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
<div class="brand">Dateiserver</div>
|
||||
<nav id="navigation" aria-label="Hauptnavigation">
|
||||
<a href="/overview" data-route="overview">Übersicht</a>
|
||||
<a href="/shares" data-route="shares">Dateifreigaben</a>
|
||||
<a href="/access" data-route="access">Zugriffsverwaltung</a>
|
||||
<a href="/reconciliation" data-route="reconciliation">Freigabenabgleich</a>
|
||||
<a href="/storage/data" data-route="storage-data">Datenbelegung</a>
|
||||
|
||||
+3
-15
@@ -9,6 +9,8 @@ body { margin: 0; min-height: 100vh; }
|
||||
a { color: #0645ad; }
|
||||
button, input, select { font: inherit; }
|
||||
button, .button { display: inline-block; padding: .4rem .7rem; border: 1px solid #777; color: #111; background: #eee; cursor: pointer; text-decoration: none; white-space: nowrap; }
|
||||
button.icon-button, .button.icon-button { display: inline-flex; align-items: center; justify-content: center; min-width: 2.4rem; min-height: 2.4rem; padding: .4rem; flex-shrink: 0; }
|
||||
.action-icon { display: block; width: 20px; height: 20px; }
|
||||
button:disabled { color: #777; cursor: wait; }
|
||||
input, select { width: 100%; padding: .4rem; border: 1px solid #999; background: #fff; }
|
||||
button:focus, input:focus, select:focus, a:focus { outline: 2px solid #0645ad; outline-offset: 1px; }
|
||||
@@ -44,12 +46,6 @@ nav a.active { font-weight: bold; background: #ddd; }
|
||||
.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: .7rem; }
|
||||
.panel-head h2 { margin: 0; }
|
||||
.split { display: grid; grid-template-columns: minmax(260px, .7fr) minmax(380px, 1.3fr); gap: 1rem; }
|
||||
.shares-view { display: flex; height: calc(100vh - 4.5rem); height: calc(100dvh - 4.5rem); min-height: 0; flex-direction: column; }
|
||||
.shares-split { min-height: 0; flex: 1; }
|
||||
.shares-split > .panel { display: flex; min-height: 0; margin-bottom: 0; flex-direction: column; }
|
||||
.shares-split .list { min-height: 0; flex: 1; overflow-y: auto; }
|
||||
.shares-split #tree-panel { display: flex; height: 100%; min-height: 0; flex-direction: column; }
|
||||
.shares-split .tree { min-height: 0; max-height: none; flex: 1; overflow-y: auto; }
|
||||
.filters { display: grid; grid-template-columns: repeat(6, minmax(110px, 1fr)); gap: .6rem; margin-bottom: 1rem; align-items: end; }
|
||||
.filters .wide { grid-column: span 2; }
|
||||
.table-wrap { width: 100%; overflow-x: auto; }
|
||||
@@ -65,16 +61,9 @@ th, td { padding: .5rem; border-bottom: 1px solid #ccc; vertical-align: top; }
|
||||
.toolbar { display: flex; flex-wrap: wrap; gap: .5rem; }
|
||||
.toolbar input { max-width: 340px; }
|
||||
.row-actions { display: flex; flex-wrap: wrap; gap: .4rem; }
|
||||
.list { margin: 0; padding: 0; list-style: none; }
|
||||
.select-row { width: 100%; display: grid; grid-template-columns: 1fr auto; gap: .5rem; border: 0; border-bottom: 1px solid #ccc; background: #fff; text-align: left; }
|
||||
.select-row.active { font-weight: bold; background: #eee; }
|
||||
.select-row span:last-child { color: #555; font-size: .85rem; }
|
||||
.tree { max-height: 65vh; overflow: auto; }
|
||||
.tree details { margin-left: 1rem; padding-left: .5rem; border-left: 1px solid #bbb; }
|
||||
.tree > details { margin-left: 0; border-left: 0; }
|
||||
.tree summary, .tree .leaf { padding: .2rem 0; }
|
||||
.tree .leaf { margin-left: 1.5rem; }
|
||||
.tree .kind { display: inline-block; min-width: 4.5rem; color: #555; }
|
||||
.empty { padding: 1rem 0; color: #666; }
|
||||
progress { width: 100%; }
|
||||
.usage-bar { min-width: 160px; }
|
||||
@@ -89,7 +78,6 @@ progress { width: 100%; }
|
||||
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.filters { grid-template-columns: repeat(3, minmax(120px, 1fr)); }
|
||||
.split { grid-template-columns: 1fr; }
|
||||
.shares-split { grid-template-rows: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.sidebar { display: none; z-index: 5; }
|
||||
@@ -115,7 +103,7 @@ progress { width: 100%; }
|
||||
.access-search, .access-status-filter { margin: 0 .8rem .65rem; }
|
||||
.access-list { border-top: 1px solid #ccc; max-height: 60vh; overflow-y: auto; }
|
||||
.access-list .access-list-item { grid-template-columns: minmax(0,1fr) auto; align-items: center; padding: .5rem .7rem; white-space: normal; }
|
||||
.access-list-item strong { font-weight: inherit; overflow-wrap: anywhere; }
|
||||
.access-list-item strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: inherit; }
|
||||
.access-list-item small { color: #555; font-size: .85rem; font-weight: normal; text-align: right; }
|
||||
.access-detail-head { display: flex; gap: 1rem; justify-content: space-between; align-items: start; padding: .8rem; }
|
||||
.access-detail-head h2 { margin: 0 0 .3rem; overflow-wrap: anywhere; }
|
||||
|
||||
+6
-2
@@ -28,9 +28,11 @@ from typing import Dict, List, Optional, Tuple
|
||||
try:
|
||||
from app import reconcile_shares as directory
|
||||
from app import trash, access_control
|
||||
from app.account_policy import is_excluded_user
|
||||
except ImportError: # Container execution uses /app as the import root.
|
||||
import reconcile_shares as directory
|
||||
import trash, access_control
|
||||
from account_policy import is_excluded_user
|
||||
|
||||
try:
|
||||
from app.audit_store import (
|
||||
@@ -520,7 +522,9 @@ class UsageScanner:
|
||||
|
||||
def snapshot(self) -> Dict[str, object]:
|
||||
with self.lock:
|
||||
return json.loads(json.dumps(self.data))
|
||||
value = json.loads(json.dumps(self.data))
|
||||
value["users"] = [row for row in value.get("users", []) if not is_excluded_user(str(row["name"]))]
|
||||
return value
|
||||
|
||||
def scan(self) -> Dict[str, object]:
|
||||
started = now_utc()
|
||||
@@ -556,7 +560,7 @@ class UsageScanner:
|
||||
"scannedAt": now_utc().isoformat(timespec="seconds"),
|
||||
"scanSeconds": round((now_utc() - started).total_seconds(), 3),
|
||||
"groups": groups,
|
||||
"users": user_rows,
|
||||
"users": [row for row in user_rows if not is_excluded_user(str(row["name"]))],
|
||||
"totals": {
|
||||
"dataBytes": sum(int(row["bytes"]) for row in groups),
|
||||
"privateBytes": sum(int(row["privateBytes"]) for row in user_rows),
|
||||
|
||||
Reference in New Issue
Block a user