Skip to content

Commit f89b699

Browse files
authored
release(v3.1.5): Pro Sources adds OneDrive + Dropbox + source-aware UX fixes
- Pro v1.6.0 adds OneDrive + Dropbox storage adapters/sources - core wire onedrive/dropbox adapters in StorageFactory and extend remote-indexing skip list - UI make previews/downloads/editor source-aware + add loading/busy feedback for create/delete/preview - ACL support group grants per source (grantsBySource) incl. Group ACL modal source selector - misc harden adapter error reporting + fix trash auto-purge + portal doc title
1 parent 7b6908d commit f89b699

18 files changed

Lines changed: 1053 additions & 150 deletions

CHANGELOG.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,72 @@
11
# Changelog
22

3+
## Changes 01/24/2026 (v3.1.5)
4+
5+
`release(v3.1.5): Pro Sources adds OneDrive + Dropbox + source-aware UX fixes`
6+
7+
**Commit message**
8+
9+
```text
10+
release(v3.1.5): Pro Sources adds OneDrive + Dropbox + source-aware UX fixes
11+
12+
- Pro v1.6.0 adds OneDrive + Dropbox storage adapters/sources
13+
- core wire onedrive/dropbox adapters in StorageFactory and extend remote-indexing skip list
14+
- UI make previews/downloads/editor source-aware + add loading/busy feedback for create/delete/preview
15+
- ACL support group grants per source (grantsBySource) incl. Group ACL modal source selector
16+
- misc harden adapter error reporting + fix trash auto-purge + portal doc title
17+
```
18+
19+
**Added**
20+
21+
- **Pro v1.6.0 Sources:** **OneDrive** + **Dropbox** adapters (new source types).
22+
- **Admin → Sources UI** fields and setup hints for OneDrive + Dropbox:
23+
- OneDrive: client id/secret/refresh token, tenant, driveId/siteId, optional root path
24+
- Dropbox: app key/secret/refresh token, optional root path + business team fields
25+
- **Group ACL per source**
26+
- Group data supports `grantsBySource` to scope group folder grants to a specific source
27+
- Group ACL modal now includes a **Source selector** so you can edit grants per source
28+
- **UX feedback**
29+
- Busy/disabled states for **Create folder** and **Create file**
30+
- Preview overlays show a **loading indicator** and “preview not available” error state
31+
- Delete flow integrates with transfer progress UI (shows totals + completion status)
32+
33+
**Changed**
34+
35+
- **Source-aware file list metadata**
36+
- File list responses now include `sourceId`
37+
- Each file entry includes `sourceId` so frontend can build correct URLs
38+
- **Preview/Download URLs now include `sourceId`**
39+
- Preview, snippet fetch, gallery thumbnails, queued downloads, and file menu actions now pass the correct source id
40+
- **Editor improvements**
41+
- Editor accepts `sourceId` + `sizeBytes` hint, shows a loading pill, supports aborting previous loads, and adds a “Saving…” state
42+
- Remote sources skip size probing that relies on Range/HEAD when not reliable
43+
- **Remote source performance guards**
44+
- Treats `ftp/sftp/webdav/smb/gdrive/onedrive/dropbox` as “slow remote sources” and skips folder stats/peek probes for them
45+
- **FileController hardening**
46+
- `saveFile` is source-aware (supports `sourceId`, blocks disabled sources for non-admins, blocks read-only sources)
47+
- `downloadFile` ensures session is active; streaming uses `set_time_limit(0)` and improved adapter error detail messages
48+
- Range openReadStream now only applies offset/length when a Range is actually requested
49+
- **S3 hints**
50+
- Sources hint text expanded to call out common S3-compatible providers (Wasabi/MinIO/B2/Spaces/R2)
51+
- **Portals**
52+
- `portal_doc_title` changed to just `{title}` (lets the portal title stand alone)
53+
54+
**Fixed**
55+
56+
- **Trash auto-purge** now correctly handles API responses that return `{ items: [...] }` instead of a raw array.
57+
- **Folder tree init order:** load folder tree after the source selector finishes initializing (prevents race conditions on boot).
58+
- **Group grants visibility** and save paths now keep `grantsBySource` intact when admin saves groups.
59+
- **Preview stability on Sources**
60+
- Prevents “wrong source” previews/downloads when panes/sources differ or when file metadata lacks a direct sourceId.
61+
62+
**Notes**
63+
64+
- OneDrive/Dropbox are **Pro Sources** (requires Pro bundle v1.6.0+).
65+
- Some remote sources don’t support “Trash” semantics; behavior remains backend-dependent (Drive already notes permanent deletes).
66+
- For best results, keep OneDrive/Dropbox root paths scoped (optional) so listings remain snappy.
67+
68+
---
69+
370
## Changes 01/20/2026 (v3.1.4)
471

572
`release(v3.1.4): restore resumable upload resume checks (testChunks) + wording polish (fixes #93)`

public/js/adminFolderAccess.js

Lines changed: 112 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ let __aclSourcesCache = null;
1515
let __aclSourceId = '';
1616

1717
function getFolderAccessSourceId() {
18+
const groupModal = document.getElementById('groupAclModal');
19+
if (groupModal && groupModal.style.display !== 'none') {
20+
const groupSel = document.getElementById('groupAclSourceSelect');
21+
const groupId = groupSel && groupSel.value ? String(groupSel.value) : '';
22+
return groupId || __aclSourceId || '';
23+
}
1824
const sel = document.getElementById('folderAccessSourceSelect');
1925
const id = sel && sel.value ? String(sel.value) : '';
2026
return id || __aclSourceId || '';
@@ -60,28 +66,35 @@ function populateFolderAccessSourceSelect(selectEl, sources, activeId) {
6066
selectEl.value = hasActive ? activeId : selectEl.options[0].value;
6167
}
6268

63-
async function initFolderAccessSourceSelector() {
64-
const row = document.getElementById('folderAccessSourceRow');
65-
const selectEl = document.getElementById('folderAccessSourceSelect');
69+
async function initFolderAccessSourceSelector(opts = {}) {
70+
const rowId = opts.rowId || 'folderAccessSourceRow';
71+
const selectId = opts.selectId || 'folderAccessSourceSelect';
72+
const onChange = typeof opts.onChange === 'function' ? opts.onChange : null;
73+
const row = document.getElementById(rowId);
74+
const selectEl = document.getElementById(selectId);
6675
if (!row || !selectEl) return;
6776

6877
const data = await loadFolderAccessSources();
6978
if (!data || !Array.isArray(data.sources) || data.sources.length <= 1) {
7079
row.style.display = 'none';
7180
__aclSourceId = (data && data.activeId) ? String(data.activeId) : '';
81+
selectEl.__onSourceChange = onChange;
7282
return;
7383
}
7484

7585
row.style.display = '';
7686
populateFolderAccessSourceSelect(selectEl, data.sources, data.activeId || '');
7787
__aclSourceId = selectEl.value || '';
88+
selectEl.__onSourceChange = onChange;
7889

7990
if (!selectEl.__wired) {
8091
selectEl.__wired = true;
8192
selectEl.addEventListener('change', () => {
8293
__aclSourceId = selectEl.value || '';
8394
__allFoldersCache = new Map();
84-
loadUserPermissionsList();
95+
if (typeof selectEl.__onSourceChange === 'function') {
96+
selectEl.__onSourceChange(__aclSourceId);
97+
}
8598
});
8699
}
87100
}
@@ -792,17 +805,74 @@ async function saveAllGroups(groups) {
792805

793806
let __groupsCache = {};
794807

795-
function computeGroupGrantMaskForUser(username, folders = []) {
808+
function normalizeSourceId(sourceId) {
809+
return String(sourceId || '').trim();
810+
}
811+
812+
function isLocalSourceId(sourceId) {
813+
const sid = normalizeSourceId(sourceId);
814+
if (!sid || sid === 'local') return true;
815+
const data = __aclSourcesCache;
816+
if (data && Array.isArray(data.sources)) {
817+
const match = data.sources.find(src => String(src.id || '') === sid);
818+
if (match) return String(match.type || '').toLowerCase() === 'local';
819+
}
820+
return false;
821+
}
822+
823+
function pickGroupGrantsForSource(group, sourceId) {
824+
if (!group || typeof group !== 'object') return {};
825+
const sid = normalizeSourceId(sourceId);
826+
const bySource = (group.grantsBySource && typeof group.grantsBySource === 'object' && !Array.isArray(group.grantsBySource))
827+
? group.grantsBySource
828+
: null;
829+
if (bySource) {
830+
if (sid && Object.prototype.hasOwnProperty.call(bySource, sid)) {
831+
const candidate = bySource[sid];
832+
return (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) ? candidate : {};
833+
}
834+
if (!sid && Object.prototype.hasOwnProperty.call(bySource, 'local')) {
835+
const candidate = bySource.local;
836+
return (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) ? candidate : {};
837+
}
838+
if (isLocalSourceId(sid)) {
839+
const legacy = group.grants;
840+
return (legacy && typeof legacy === 'object' && !Array.isArray(legacy)) ? legacy : {};
841+
}
842+
return {};
843+
}
844+
if (isLocalSourceId(sid)) {
845+
const legacy = group.grants;
846+
return (legacy && typeof legacy === 'object' && !Array.isArray(legacy)) ? legacy : {};
847+
}
848+
return {};
849+
}
850+
851+
function setGroupGrantsForSource(group, sourceId, grants) {
852+
if (!group || typeof group !== 'object') return;
853+
const sid = normalizeSourceId(sourceId);
854+
if (!sid) {
855+
group.grants = grants;
856+
return;
857+
}
858+
if (!group.grantsBySource || typeof group.grantsBySource !== 'object' || Array.isArray(group.grantsBySource)) {
859+
group.grantsBySource = {};
860+
}
861+
group.grantsBySource[sid] = grants;
862+
}
863+
864+
function computeGroupGrantMaskForUser(username, folders = [], sourceId = null) {
796865
const mask = {};
797866
if (!username || !__groupsCache) return mask;
867+
const sid = normalizeSourceId(sourceId == null ? getFolderAccessSourceId() : sourceId);
798868
const uname = String(username).toLowerCase();
799869

800870
const userGroups = Object.keys(__groupsCache || {}).map(groupName => {
801871
const g = __groupsCache[groupName] || {};
802872
const members = Array.isArray(g.members) ? g.members : [];
803873
const inGroup = members.some(m => String(m || "").toLowerCase() === uname);
804874
if (!inGroup) return null;
805-
return { name: groupName, grants: g.grants || {} };
875+
return { name: groupName, grants: pickGroupGrantsForSource(g, sid) };
806876
}).filter(Boolean);
807877

808878
if (!userGroups.length) return mask;
@@ -1091,7 +1161,9 @@ export function openUserPermissionsModal(initialUser = null) {
10911161
userPermissionsModal.style.display = "flex";
10921162
}
10931163

1094-
initFolderAccessSourceSelector().finally(() => {
1164+
initFolderAccessSourceSelector({
1165+
onChange: () => loadUserPermissionsList()
1166+
}).finally(() => {
10951167
loadUserPermissionsList();
10961168
});
10971169
}
@@ -1109,7 +1181,7 @@ export async function openUserGroupsModal() {
11091181
modal.id = 'userGroupsModal';
11101182
modal.style.cssText = `
11111183
position:fixed; inset:0; background:${overlayBg};
1112-
display:flex; align-items:center; justify-content:center; z-index:3650;
1184+
display:flex; align-items:center; justify-content:center; z-index:3750;
11131185
`;
11141186
modal.innerHTML = `
11151187
<div class="modal-content"
@@ -1260,7 +1332,7 @@ async function loadUserPermissionsList() {
12601332
if (grantsBox.dataset.loaded === "1") return;
12611333
try {
12621334
const group = __groupsCache[name] || {};
1263-
const grants = group.grants || {};
1335+
const grants = pickGroupGrantsForSource(group, sourceId);
12641336

12651337
renderFolderGrantsUI(
12661338
name,
@@ -1386,7 +1458,7 @@ async function loadUserPermissionsList() {
13861458
);
13871459

13881460
if (!isAdmin && groupNamesForUser.length) {
1389-
const groupMask = computeGroupGrantMaskForUser(username, orderedFolders);
1461+
const groupMask = computeGroupGrantMaskForUser(username, orderedFolders, sourceId);
13901462
applyGroupLocksForUser(username, grantsBox, groupMask, groupNamesForUser);
13911463
}
13921464

@@ -1656,13 +1728,16 @@ async function saveUserGroupsFromUI() {
16561728
const label = (labelEl && labelEl.value || '').trim() || name;
16571729
const members = Array.from(membersSel && membersSel.selectedOptions || []).map(o => o.value);
16581730

1659-
const existing = __groupsCache[oldName] || __groupsCache[name] || { grants: {} };
1731+
const existing = __groupsCache[oldName] || __groupsCache[name] || { grants: {}, grantsBySource: {} };
16601732
groups[name] = {
16611733
name,
16621734
label,
16631735
members,
16641736
grants: existing.grants || {}
16651737
};
1738+
if (existing.grantsBySource && typeof existing.grantsBySource === 'object' && !Array.isArray(existing.grantsBySource)) {
1739+
groups[name].grantsBySource = existing.grantsBySource;
1740+
}
16661741
});
16671742

16681743
if (status) {
@@ -1710,7 +1785,7 @@ async function openGroupAclEditor(groupName) {
17101785
modal.id = 'groupAclModal';
17111786
modal.style.cssText = `
17121787
position:fixed; inset:0; background:${overlayBg};
1713-
display:flex; align-items:center; justify-content:center; z-index:3700;
1788+
display:flex; align-items:center; justify-content:center; z-index:3800;
17141789
`;
17151790
modal.innerHTML = `
17161791
<div class="modal-content"
@@ -1727,6 +1802,11 @@ async function openGroupAclEditor(groupName) {
17271802
Group grants are merged with each member’s own folder access. They never reduce access.
17281803
</div>
17291804
1805+
<div class="modal-source-row" id="groupAclSourceRow" style="display:none;">
1806+
<label for="groupAclSourceSelect">${tf("storage_source", "Source")}</label>
1807+
<select id="groupAclSourceSelect" class="form-control form-control-sm"></select>
1808+
</div>
1809+
17301810
<div id="groupAclBody" style="max-height:70vh; overflow-y:auto; margin-bottom:12px;"></div>
17311811
17321812
<div style="display:flex; justify-content:flex-end; gap:8px;">
@@ -1759,19 +1839,30 @@ async function openGroupAclEditor(groupName) {
17591839
modal.dataset.groupName = groupName;
17601840
modal.style.display = 'flex';
17611841

1762-
const sourceId = getFolderAccessSourceId();
1763-
const folders = await getAllFolders(true, sourceId);
1764-
const grants = (__groupsCache[groupName] && __groupsCache[groupName].grants) || {};
1842+
const renderGroupAcl = async () => {
1843+
const bodyEl = document.getElementById('groupAclBody');
1844+
if (!bodyEl) return;
1845+
bodyEl.textContent = `${t('loading')}…`;
17651846

1766-
if (body) {
1767-
body.textContent = '';
1847+
const sourceId = getFolderAccessSourceId();
1848+
const folders = await getAllFolders(true, sourceId);
1849+
const grants = pickGroupGrantsForSource(__groupsCache[groupName] || {}, sourceId);
1850+
1851+
bodyEl.textContent = '';
17681852
const box = document.createElement('div');
17691853
box.className = 'folder-grants-box';
1770-
body.appendChild(box);
1854+
bodyEl.appendChild(box);
17711855

17721856
renderFolderGrantsUI(groupName, box, ['root', ...folders.filter(f => f !== 'root')], grants);
17731857
box.__grantsFallback = grants;
1774-
}
1858+
};
1859+
1860+
await initFolderAccessSourceSelector({
1861+
rowId: 'groupAclSourceRow',
1862+
selectId: 'groupAclSourceSelect',
1863+
onChange: renderGroupAcl
1864+
});
1865+
await renderGroupAcl();
17751866
}
17761867

17771868
function saveGroupAclFromUI() {
@@ -1786,10 +1877,11 @@ function saveGroupAclFromUI() {
17861877
if (!box) return;
17871878

17881879
const grants = collectGrantsFrom(box, box.__grantsFallback || {});
1880+
const sourceId = getFolderAccessSourceId();
17891881
if (!__groupsCache[groupName]) {
17901882
__groupsCache[groupName] = { name: groupName, label: groupName, members: [], grants: {} };
17911883
}
1792-
__groupsCache[groupName].grants = grants;
1884+
setGroupGrantsForSource(__groupsCache[groupName], sourceId, grants);
17931885

17941886
showToast(t('admin_group_access_updated'));
17951887
modal.style.display = 'none';

0 commit comments

Comments
 (0)