diff --git a/CHANGELOG.md b/CHANGELOG.md index 56c1f46..731c86f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## Changes 11/19/2025 (v1.9.12) + +release(v1.9.12): feat(pro-acl): add user groups and group-aware ACL + +- Add Pro user groups as a first-class ACL source: + - Load group grants from FR_PRO_BUNDLE_DIR/groups.json in ACL::hasGrant(). + - Treat group grants as additive only; they can never remove access. + +- Introduce AclAdminController: + - Move getGrants/saveGrants logic into a dedicated controller. + - Keep existing ACL normalization and business rules (shareFolder ⇒ view, shareFile ⇒ at least viewOwn). + - Refactor public/api/admin/acl/getGrants.php and saveGrants.php to use the controller. + +- Implement Pro user group storage and APIs: + - Add ProGroups store class under FR_PRO_BUNDLE_DIR (groups.json with {name,label,members,grants}). + - Add /api/pro/groups/list.php and /api/pro/groups/save.php, guarded by AdminController::requireAuth/requireAdmin/requireCsrf(). + - Keep groups and bundle code behind FR_PRO_ACTIVE/FR_PRO_BUNDLE_DIR checks. + +- Ship Pro-only endpoints from core instead of the bundle: + - Move public/api/pro/uploadBrandLogo.php into core and gate it on FR_PRO_ACTIVE. + - Remove start.sh logic that copied public/api/pro from the Pro bundle into the container image. + +- Extend admin UI for user groups: + - Turn “User groups” into a real Pro-only modal with add/delete groups, multi-select members, and member chips. + - Add “Edit folder access” for each group, reusing the existing folder grants grid. + - Overlay group grants when editing a user’s ACL: + - Show which caps are coming from groups, lock those checkboxes, and update tooltips. + - Show group membership badges in the user permissions list. + - Add a collapsed “Groups” section at the top of the permissions screen to preview group ACLs (read-only). + +- Misc: + - Bump PRO_LATEST_BUNDLE_VERSION hint in adminPanel.js to v1.0.1. + - Tweak modal border-radius styling to include the new userGroups and groupAcl modals. + +--- + ## Changes 11/18/2025 (v1.9.11) release(v1.9.11): fix(media): HTTP Range streaming; feat(ui): paged folder strip (closes #68) diff --git a/public/api/admin/acl/getGrants.php b/public/api/admin/acl/getGrants.php index 6898a13..82ae7ca 100644 --- a/public/api/admin/acl/getGrants.php +++ b/public/api/admin/acl/getGrants.php @@ -3,83 +3,26 @@ declare(strict_types=1); require_once __DIR__ . '/../../../../config/config.php'; -require_once PROJECT_ROOT . '/src/lib/ACL.php'; -require_once PROJECT_ROOT . '/src/models/FolderModel.php'; +require_once PROJECT_ROOT . '/src/controllers/AclAdminController.php'; if (session_status() !== PHP_SESSION_ACTIVE) session_start(); header('Content-Type: application/json'); if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) { - http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; + http_response_code(401); + echo json_encode(['error' => 'Unauthorized']); + exit; } $user = trim((string)($_GET['user'] ?? '')); -if ($user === '' || !preg_match(REGEX_USER, $user)) { - http_response_code(400); echo json_encode(['error'=>'Invalid user']); exit; -} - -// Build the folder list (admin sees all) -$folders = []; try { - $rows = FolderModel::getFolderList(); - if (is_array($rows)) { - foreach ($rows as $r) { - $f = is_array($r) ? ($r['folder'] ?? '') : (string)$r; - if ($f !== '') $folders[$f] = true; - } - } -} catch (Throwable $e) { /* ignore */ } - -if (empty($folders)) { - $aclPath = rtrim(META_DIR, "/\\") . DIRECTORY_SEPARATOR . 'folder_acl.json'; - if (is_file($aclPath)) { - $data = json_decode((string)@file_get_contents($aclPath), true); - if (is_array($data['folders'] ?? null)) { - foreach ($data['folders'] as $name => $_) $folders[$name] = true; - } - } -} - -$folderList = array_keys($folders); -if (!in_array('root', $folderList, true)) array_unshift($folderList, 'root'); - -$has = function(array $arr, string $u): bool { - foreach ($arr as $x) if (strcasecmp((string)$x, $u) === 0) return true; - return false; -}; - -$out = []; -foreach ($folderList as $f) { - $rec = ACL::explicitAll($f); // legacy + granular - - $isOwner = $has($rec['owners'], $user); - $canViewAll = $isOwner || $has($rec['read'], $user); - $canViewOwn = $has($rec['read_own'], $user); - $canShare = $isOwner || $has($rec['share'], $user); - $canUpload = $isOwner || $has($rec['write'], $user) || $has($rec['upload'], $user); - - if ($canViewAll || $canViewOwn || $canUpload || $canShare || $isOwner - || $has($rec['create'],$user) || $has($rec['edit'],$user) || $has($rec['rename'],$user) - || $has($rec['copy'],$user) || $has($rec['move'],$user) || $has($rec['delete'],$user) - || $has($rec['extract'],$user) || $has($rec['share_file'],$user) || $has($rec['share_folder'],$user)) { - $out[$f] = [ - 'view' => $canViewAll, - 'viewOwn' => $canViewOwn, - 'write' => $has($rec['write'], $user) || $isOwner, - 'manage' => $isOwner, - 'share' => $canShare, // legacy - 'create' => $isOwner || $has($rec['create'], $user), - 'upload' => $isOwner || $has($rec['upload'], $user) || $has($rec['write'],$user), - 'edit' => $isOwner || $has($rec['edit'], $user) || $has($rec['write'],$user), - 'rename' => $isOwner || $has($rec['rename'], $user) || $has($rec['write'],$user), - 'copy' => $isOwner || $has($rec['copy'], $user) || $has($rec['write'],$user), - 'move' => $isOwner || $has($rec['move'], $user) || $has($rec['write'],$user), - 'delete' => $isOwner || $has($rec['delete'], $user) || $has($rec['write'],$user), - 'extract' => $isOwner || $has($rec['extract'], $user)|| $has($rec['write'],$user), - 'shareFile' => $isOwner || $has($rec['share_file'], $user) || $has($rec['share'],$user), - 'shareFolder' => $isOwner || $has($rec['share_folder'], $user) || $has($rec['share'],$user), - ]; - } -} - -echo json_encode(['grants' => $out], JSON_UNESCAPED_SLASHES); + $ctrl = new AclAdminController(); + $grants = $ctrl->getUserGrants($user); + echo json_encode(['grants' => $grants], JSON_UNESCAPED_SLASHES); +} catch (InvalidArgumentException $e) { + http_response_code(400); + echo json_encode(['error' => $e->getMessage()]); +} catch (Throwable $e) { + http_response_code(500); + echo json_encode(['error' => 'Failed to load grants', 'detail' => $e->getMessage()]); +} \ No newline at end of file diff --git a/public/api/admin/acl/saveGrants.php b/public/api/admin/acl/saveGrants.php index 761c5aa..4d5a1a1 100644 --- a/public/api/admin/acl/saveGrants.php +++ b/public/api/admin/acl/saveGrants.php @@ -3,12 +3,11 @@ declare(strict_types=1); require_once __DIR__ . '/../../../../config/config.php'; -require_once PROJECT_ROOT . '/src/lib/ACL.php'; +require_once PROJECT_ROOT . '/src/controllers/AclAdminController.php'; if (session_status() !== PHP_SESSION_ACTIVE) session_start(); header('Content-Type: application/json'); -// ---- Auth + CSRF ----------------------------------------------------------- if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) { http_response_code(401); echo json_encode(['error' => 'Unauthorized']); @@ -24,98 +23,17 @@ if (empty($_SESSION['csrf_token']) || $csrf !== $_SESSION['csrf_token']) { exit; } -// ---- Helpers --------------------------------------------------------------- -function normalize_caps(array $row): array { - // booleanize known keys - $bool = function($v){ return !empty($v) && $v !== 'false' && $v !== 0; }; - $k = [ - 'view','viewOwn','upload','manage','share', - 'create','edit','rename','copy','move','delete','extract', - 'shareFile','shareFolder','write' - ]; - $out = []; - foreach ($k as $kk) $out[$kk] = $bool($row[$kk] ?? false); - - // BUSINESS RULES: - // A) Share Folder REQUIRES View (all). If shareFolder is true but view is false, force view=true. - if ($out['shareFolder'] && !$out['view']) { - $out['view'] = true; - } - - // B) Share File requires at least View (own). If neither view nor viewOwn set, set viewOwn=true. - if ($out['shareFile'] && !$out['view'] && !$out['viewOwn']) { - $out['viewOwn'] = true; - } - - // C) "write" does NOT imply view. It also does not imply granular here; ACL expands legacy write if present. - return $out; -} - -function sanitize_grants_map(array $grants): array { - $out = []; - foreach ($grants as $folder => $caps) { - if (!is_string($folder)) $folder = (string)$folder; - if (!is_array($caps)) $caps = []; - $out[$folder] = normalize_caps($caps); - } - return $out; -} - -function valid_user(string $u): bool { - return ($u !== '' && preg_match(REGEX_USER, $u)); -} - -// ---- Read JSON body -------------------------------------------------------- $raw = file_get_contents('php://input'); $in = json_decode((string)$raw, true); -if (!is_array($in)) { + +try { + $ctrl = new AclAdminController(); + $res = $ctrl->saveUserGrantsPayload($in ?? []); + echo json_encode($res, JSON_UNESCAPED_SLASHES); +} catch (InvalidArgumentException $e) { http_response_code(400); - echo json_encode(['error' => 'Invalid JSON']); - exit; -} - -// ---- Single user mode: { user, grants } ------------------------------------ -if (isset($in['user']) && isset($in['grants']) && is_array($in['grants'])) { - $user = trim((string)$in['user']); - if (!valid_user($user)) { - http_response_code(400); - echo json_encode(['error' => 'Invalid user']); - exit; - } - - $grants = sanitize_grants_map($in['grants']); - - try { - $res = ACL::applyUserGrantsAtomic($user, $grants); - echo json_encode($res, JSON_UNESCAPED_SLASHES); - exit; - } catch (Throwable $e) { - http_response_code(500); - echo json_encode(['error' => 'Failed to save grants', 'detail' => $e->getMessage()]); - exit; - } -} - -// ---- Batch mode: { changes: [ { user, grants }, ... ] } -------------------- -if (isset($in['changes']) && is_array($in['changes'])) { - $updated = []; - foreach ($in['changes'] as $chg) { - if (!is_array($chg)) continue; - $user = trim((string)($chg['user'] ?? '')); - $gr = $chg['grants'] ?? null; - if (!valid_user($user) || !is_array($gr)) continue; - - try { - $res = ACL::applyUserGrantsAtomic($user, sanitize_grants_map($gr)); - $updated[$user] = $res['updated'] ?? []; - } catch (Throwable $e) { - $updated[$user] = ['error' => $e->getMessage()]; - } - } - echo json_encode(['ok' => true, 'updated' => $updated], JSON_UNESCAPED_SLASHES); - exit; -} - -// ---- Fallback -------------------------------------------------------------- -http_response_code(400); -echo json_encode(['error' => 'Invalid payload: expected {user,grants} or {changes:[{user,grants}]}']); + echo json_encode(['error' => $e->getMessage()]); +} catch (Throwable $e) { + http_response_code(500); + echo json_encode(['error' => 'Failed to save grants', 'detail' => $e->getMessage()]); +} \ No newline at end of file diff --git a/public/api/pro/groups/list.php b/public/api/pro/groups/list.php new file mode 100644 index 0000000..682ca88 --- /dev/null +++ b/public/api/pro/groups/list.php @@ -0,0 +1,32 @@ +getProGroups(); + + echo json_encode([ + 'success' => true, + 'groups' => $groups, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +} catch (Throwable $e) { + $code = $e instanceof InvalidArgumentException ? 400 : 500; + http_response_code($code); + echo json_encode([ + 'success' => false, + 'error' => 'Error loading groups: ' . $e->getMessage(), + ]); +} \ No newline at end of file diff --git a/public/api/pro/groups/save.php b/public/api/pro/groups/save.php new file mode 100644 index 0000000..f57dfcd --- /dev/null +++ b/public/api/pro/groups/save.php @@ -0,0 +1,51 @@ + false, 'error' => 'Method not allowed']); + return; + } + + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + + AdminController::requireAuth(); + AdminController::requireAdmin(); + AdminController::requireCsrf(); + + $raw = file_get_contents('php://input'); + $body = json_decode($raw, true); + if (!is_array($body)) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Invalid JSON payload.']); + return; + } + + $groups = $body['groups'] ?? null; + if (!is_array($groups)) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Invalid groups format.']); + return; + } + + $ctrl = new AdminController(); + $ctrl->saveProGroups($groups); + + echo json_encode(['success' => true], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +} catch (Throwable $e) { + $code = $e instanceof InvalidArgumentException ? 400 : 500; + http_response_code($code); + echo json_encode([ + 'success' => false, + 'error' => 'Error saving groups: ' . $e->getMessage(), + ]); +} \ No newline at end of file diff --git a/public/api/pro/uploadBrandLogo.php b/public/api/pro/uploadBrandLogo.php new file mode 100644 index 0000000..3fc0384 --- /dev/null +++ b/public/api/pro/uploadBrandLogo.php @@ -0,0 +1,28 @@ + false, + 'error' => 'FileRise Pro is not active on this instance.' + ]); + exit; +} + +try { + $ctrl = new UserController(); + $ctrl->uploadBrandLogo(); +} catch (Throwable $e) { + http_response_code(500); + echo json_encode([ + 'success' => false, + 'error' => 'Exception: ' . $e->getMessage(), + ]); +} diff --git a/public/css/styles.css b/public/css/styles.css index 303aa88..69ee746 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -6,7 +6,9 @@ img.logo{width:50px; height:50px; display:block;} #userPanelModal .modal-content, #adminPanelModal .modal-content, #userPermissionsModal .modal-content, -#userFlagsModal .modal-content{border-radius: var(--menu-radius);} +#userFlagsModal .modal-content, +#userGroupsModal .modal-content, +#groupAclModal .modal-content{border-radius: var(--menu-radius);} #fr-login-tip{min-height: 40px; max-width: 520px; margin: 8px auto 0; diff --git a/public/js/adminPanel.js b/public/js/adminPanel.js index 7175757..9f045dd 100644 --- a/public/js/adminPanel.js +++ b/public/js/adminPanel.js @@ -16,7 +16,7 @@ function normalizeLogoPath(raw) { const version = window.APP_VERSION || "dev"; // Hard-coded *FOR NOW* latest FileRise Pro bundle version for UI hints only. // Update this when I cut a new Pro ZIP. -const PRO_LATEST_BUNDLE_VERSION = 'v1.0.0'; +const PRO_LATEST_BUNDLE_VERSION = 'v1.0.1'; function getAdminTitle(isPro, proVersion) { const corePill = ` @@ -405,6 +405,27 @@ async function safeJson(res) { font-weight:600; min-width:0; /* allow child to be as wide as needed inside scroller */ } + .group-members-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; + } + + .group-member-pill { + display: inline-flex; + align-items: center; + padding: 2px 6px; + border-radius: 999px; + font-size: 11px; + background-color: #1e88e5; + color: #fff; + } + + .dark-mode .group-member-pill { + background-color: #1565c0; + color: #fff; + } + `; document.head.appendChild(style); })(); @@ -782,7 +803,7 @@ export function openAdminPanel() { groups User groups - Pro · Coming soon + Pro ` } @@ -821,7 +842,7 @@ export function openAdminPanel() { Use the core tools to manage users and per-folder access. - User groups and Client upload portals are planned FileRise Pro features. + User groups are available in Pro and Client upload portals are coming soon. `; @@ -862,8 +883,7 @@ export function openAdminPanel() { window.open("https://filerise.net", "_blank", "noopener"); return; } - // Placeholder for future Pro UI: - showToast("User groups management is coming soon in FileRise Pro."); + openUserGroupsModal(); }); } @@ -1894,6 +1914,97 @@ async function getUserGrants(username) { return (data && data.grants) ? data.grants : {}; } +function computeGroupGrantMaskForUser(username) { + const result = {}; + const uname = (username || "").trim().toLowerCase(); + if (!uname) return result; + if (!__groupsCache || typeof __groupsCache !== "object") return result; + + Object.keys(__groupsCache).forEach(gName => { + const g = __groupsCache[gName] || {}; + const members = Array.isArray(g.members) ? g.members : []; + const isMember = members.some(m => String(m || "").trim().toLowerCase() === uname); + if (!isMember) return; + + const grants = g.grants && typeof g.grants === "object" ? g.grants : {}; + Object.keys(grants).forEach(folder => { + const fg = grants[folder]; + if (!fg || typeof fg !== "object") return; + if (!result[folder]) result[folder] = {}; + Object.keys(fg).forEach(capKey => { + if (fg[capKey]) { + result[folder][capKey] = true; + } + }); + }); + }); + + return result; +} + +function applyGroupLocksForUser(username, grantsBox, groupMask, groupsForUser) { + if (!grantsBox || !groupMask) return; + + const groupLabels = (groupsForUser || []).map(name => { + const g = __groupsCache && __groupsCache[name] || {}; + return g.label || name; + }); + const labelStr = groupLabels.join(", "); + + const rows = grantsBox.querySelectorAll(".folder-access-row"); + rows.forEach(row => { + const folder = row.dataset.folder || ""; + const capsForFolder = groupMask[folder]; + if (!capsForFolder) return; + + Object.keys(capsForFolder).forEach(capKey => { + if (!capsForFolder[capKey]) return; + + // Map caps to actual columns we have in the UI + let uiCaps = []; + switch (capKey) { + case "view": + case "viewOwn": + case "manage": + case "create": + case "upload": + case "edit": + case "rename": + case "copy": + case "move": + case "delete": + case "extract": + case "shareFile": + case "shareFolder": + uiCaps = [capKey]; + break; + case "write": + uiCaps = ["create", "upload", "edit", "rename", "copy", "delete", "extract"]; + break; + case "share": + uiCaps = ["shareFile", "shareFolder"]; + break; + default: + // unknown / unsupported cap key in UI + return; + } + + uiCaps.forEach(c => { + const cb = row.querySelector(`input[type="checkbox"][data-cap="${c}"]`); + if (!cb) return; + cb.checked = true; + cb.disabled = true; + cb.setAttribute("data-hard-disabled", "1"); + + let baseTitle = "Granted via group"; + if (groupLabels.length > 1) baseTitle += "s"; + if (labelStr) baseTitle += `: ${labelStr}`; + cb.title = baseTitle + ". Edit group permissions in User groups to change."; + }); + }); + }); +} + function renderFolderGrantsUI(username, container, folders, grants) { container.innerHTML = ""; @@ -2325,6 +2436,430 @@ async function fetchAllUsers() { return await r.json(); } +async function fetchAllGroups() { + const res = await fetch('/api/pro/groups/list.php', { + credentials: 'include', + headers: { 'X-CSRF-Token': window.csrfToken || '' } + }); + const data = await safeJson(res); + // backend returns { success, groups: { name: {...} } } + return data && typeof data === 'object' && data.groups && typeof data.groups === 'object' + ? data.groups + : {}; +} + +async function saveAllGroups(groups) { + const res = await fetch('/api/pro/groups/save.php', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': window.csrfToken || '' + }, + body: JSON.stringify({ groups }) + }); + return await safeJson(res); +} + +let __groupsCache = {}; + +async function openUserGroupsModal() { + const isDark = document.body.classList.contains('dark-mode'); + const overlayBg = isDark ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.3)'; + const contentBg = isDark ? '#2c2c2c' : '#fff'; + const contentFg = isDark ? '#e0e0e0' : '#000'; + const borderCol = isDark ? '#555' : '#ccc'; + + let modal = document.getElementById('userGroupsModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'userGroupsModal'; + modal.style.cssText = ` + position:fixed; inset:0; background:${overlayBg}; + display:flex; align-items:center; justify-content:center; z-index:3650; + `; + modal.innerHTML = ` +
+ `; + document.body.appendChild(modal); + + document.getElementById('closeUserGroupsModal').onclick = () => (modal.style.display = 'none'); + document.getElementById('cancelUserGroups').onclick = () => (modal.style.display = 'none'); + document.getElementById('saveUserGroups').onclick = saveUserGroupsFromUI; + document.getElementById('addGroupBtn').onclick = addEmptyGroupRow; + } else { + modal.style.background = overlayBg; + const content = modal.querySelector('.modal-content'); + if (content) { + content.style.background = contentBg; + content.style.color = contentFg; + content.style.border = `1px solid ${borderCol}`; + } + } + + modal.style.display = 'flex'; + await loadUserGroupsList(); +} + +async function loadUserGroupsList(useCacheOnly) { + const body = document.getElementById('userGroupsBody'); + const status = document.getElementById('userGroupsStatus'); + if (!body) return; + + body.textContent = `${t('loading')}…`; + if (status) { + status.textContent = ''; + status.className = 'small text-muted'; + } + + try { + // Users always come fresh (or you could cache if you want) + const users = await fetchAllUsers(); + + let groups; + if (useCacheOnly && __groupsCache && Object.keys(__groupsCache).length) { + // When we’re just re-rendering after local edits, don’t clobber cache + groups = __groupsCache; + } else { + // Initial load, or explicit refresh – pull from server + groups = await fetchAllGroups(); + __groupsCache = groups || {}; + } + + const usernames = users + .map(u => String(u.username || '').trim()) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)); + + const groupNames = Object.keys(__groupsCache).sort((a, b) => a.localeCompare(b)); + if (!groupNames.length) { + body.innerHTML = `${tf('no_groups_defined', 'No groups defined yet. Click “Add group” to create one.')}
`; + return; + } + + let html = ''; + groupNames.forEach(name => { + const g = __groupsCache[name] || {}; + const label = g.label || name; + const members = Array.isArray(g.members) ? g.members : []; + + const memberOptions = usernames.map(u => { + const sel = members.includes(u) ? 'selected' : ''; + return ``; + }).join(''); + + html += ` +${tf('error_loading_groups', 'Error loading groups')}
`; + } +} + +function addEmptyGroupRow() { + if (!__groupsCache || typeof __groupsCache !== 'object') { + __groupsCache = {}; + } + let idx = 1; + let name = `group${idx}`; + while (__groupsCache[name]) { + idx += 1; + name = `group${idx}`; + } + __groupsCache[name] = { name, label: name, members: [], grants: {} }; + // Re-render using local cache only; don't clobber with server (which is still empty) + loadUserGroupsList(true); +} + +async function saveUserGroupsFromUI() { + const body = document.getElementById('userGroupsBody'); + const status = document.getElementById('userGroupsStatus'); + if (!body) return; + + const cards = body.querySelectorAll('[data-group-name]'); + const groups = {}; + + cards.forEach(card => { + const oldName = card.getAttribute('data-group-name') || ''; + const nameEl = card.querySelector('input[data-group-field="name"]'); + const labelEl = card.querySelector('input[data-group-field="label"]'); + const membersSel = card.querySelector('select[data-group-field="members"]'); + + const name = (nameEl && nameEl.value || '').trim(); + if (!name) return; + + const label = (labelEl && labelEl.value || '').trim() || name; + const members = Array.from(membersSel && membersSel.selectedOptions || []).map(o => o.value); + + const existing = __groupsCache[oldName] || __groupsCache[name] || { grants: {} }; + groups[name] = { + name, + label, + members, + grants: existing.grants || {} + }; + }); + + if (status) { + status.textContent = 'Saving groups…'; + status.className = 'small text-muted'; + } + + try { + const res = await saveAllGroups(groups); + if (!res.success) { + showToast(res.error || 'Error saving groups'); + if (status) { + status.textContent = 'Error saving groups.'; + status.className = 'small text-danger'; + } + return; + } + + __groupsCache = groups; + if (status) { + status.textContent = 'Groups saved.'; + status.className = 'small text-success'; + } + showToast('Groups saved.'); + } catch (e) { + console.error(e); + if (status) { + status.textContent = 'Error saving groups.'; + status.className = 'small text-danger'; + } + showToast('Error saving groups', 'error'); + } +} + +async function openGroupAclEditor(groupName) { + const isDark = document.body.classList.contains('dark-mode'); + const overlayBg = isDark ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.3)'; + const contentBg = isDark ? '#2c2c2c' : '#fff'; + const contentFg = isDark ? '#e0e0e0' : '#000'; + const borderCol = isDark ? '#555' : '#ccc'; + + let modal = document.getElementById('groupAclModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'groupAclModal'; + modal.style.cssText = ` + position:fixed; inset:0; background:${overlayBg}; + display:flex; align-items:center; justify-content:center; z-index:3700; + `; + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + + document.getElementById('closeGroupAclModal').onclick = () => (modal.style.display = 'none'); + document.getElementById('cancelGroupAcl').onclick = () => (modal.style.display = 'none'); + document.getElementById('saveGroupAcl').onclick = saveGroupAclFromUI; + } else { + modal.style.background = overlayBg; + const content = modal.querySelector('.modal-content'); + if (content) { + content.style.background = contentBg; + content.style.color = contentFg; + content.style.border = `1px solid ${borderCol}`; + } + } + + const title = document.getElementById('groupAclTitle'); + if (title) title.textContent = `Group folder access: ${groupName}`; + + const body = document.getElementById('groupAclBody'); + if (body) body.textContent = `${t('loading')}…`; + + modal.dataset.groupName = groupName; + modal.style.display = 'flex'; + + const folders = await getAllFolders(true); + const grants = (__groupsCache[groupName] && __groupsCache[groupName].grants) || {}; + + if (body) { + body.textContent = ''; + const box = document.createElement('div'); + box.className = 'folder-grants-box'; + body.appendChild(box); + + renderFolderGrantsUI(groupName, box, ['root', ...folders.filter(f => f !== 'root')], grants); + } +} + +function saveGroupAclFromUI() { + const modal = document.getElementById('groupAclModal'); + if (!modal) return; + const groupName = modal.dataset.groupName; + if (!groupName) return; + + const body = document.getElementById('groupAclBody'); + if (!body) return; + const box = body.querySelector('.folder-grants-box'); + if (!box) return; + + const grants = collectGrantsFrom(box); + if (!__groupsCache[groupName]) { + __groupsCache[groupName] = { name: groupName, label: groupName, members: [], grants: {} }; + } + __groupsCache[groupName].grants = grants; + + showToast('Group folder access updated. Remember to Save groups.'); + modal.style.display = 'none'; +} + + async function fetchAllUserFlags() { const r = await fetch("/api/getUserPermissions.php", { credentials: "include" }); const data = await r.json(); @@ -2489,31 +3024,194 @@ async function loadUserPermissionsList() { listContainer.innerHTML = `${t("loading")}…
`; try { - const usersRes = await fetch("/api/getUsers.php", { credentials: "include" }); - const usersData = await safeJson(usersRes); - const users = Array.isArray(usersData) ? usersData : (usersData.users || []); - if (!users.length) { + // Load users + groups together (folders separately) + const [usersRes, groupsMap] = await Promise.all([ + fetch("/api/getUsers.php", { credentials: "include" }).then(safeJson), + fetchAllGroups().catch(() => ({})) + ]); + + const users = Array.isArray(usersRes) ? usersRes : (usersRes.users || []); + const groups = groupsMap && typeof groupsMap === "object" ? groupsMap : {}; + + if (!users.length && !Object.keys(groups).length) { listContainer.innerHTML = "" + t("no_users_found") + "
"; return; } - const folders = await getAllFolders(true); + // Keep cache in sync with the groups UI + __groupsCache = groups || {}; + const folders = await getAllFolders(true); + const orderedFolders = ["root", ...folders.filter(f => f !== "root")]; + + // Build map: username -> [groupName, ...] + const userGroupMap = {}; + Object.keys(groups).forEach(gName => { + const g = groups[gName] || {}; + const members = Array.isArray(g.members) ? g.members : []; + members.forEach(m => { + const u = String(m || "").trim(); + if (!u) return; + if (!userGroupMap[u]) userGroupMap[u] = []; + userGroupMap[u].push(gName); + }); + }); + + // Clear the container and render sections listContainer.innerHTML = ""; - users.forEach(user => { - const isAdmin = (user.role && String(user.role) === "1") || String(user.username).toLowerCase() === "admin"; + +// ==================== +// Groups section (top) +// ==================== +const groupNames = Object.keys(groups).sort((a, b) => a.localeCompare(b)); +if (groupNames.length) { + const groupHeader = document.createElement("div"); + groupHeader.className = "muted"; + groupHeader.style.margin = "4px 0 6px"; + groupHeader.textContent = tf("groups_header", "Groups"); + listContainer.appendChild(groupHeader); + + groupNames.forEach(name => { + const g = groups[name] || {}; + const label = g.label || name; + const members = Array.isArray(g.members) ? g.members : []; + const membersSummary = members.length + ? members.join(", ") + : tf("no_members", "No members yet"); + + const row = document.createElement("div"); + row.classList.add("user-permission-row", "group-permission-row"); + row.setAttribute("data-group-name", name); + row.style.padding = "6px 0"; + + row.innerHTML = ` +