diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dac7e6e..f4c882b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,6 +131,8 @@ jobs: --exclude='.wordpress-org' \ --exclude='.worktrees' \ --exclude='AGENTS.md' \ + --exclude='CITATION.cff' \ + --exclude='COPYRIGHT.md' \ --exclude='auth.json' \ --exclude='CODE_OF_CONDUCT.md' \ --exclude='CONTRIBUTING.md' \ @@ -161,10 +163,13 @@ jobs: node-version: '24' - name: Test Plugin Check output parser - run: node --test tests/plugin-check-output.test.mjs + run: | + node --test tests/plugin-check-output.test.mjs + node --test tests/pdm-helpers.test.mjs - name: Check JavaScript syntax run: | + node --check assets/js/admin-app-helpers.js node --check assets/js/admin-app-core.js node --check assets/js/admin-app-governance.js node --check assets/js/admin-app.js diff --git a/AGENTS.md b/AGENTS.md index 990cd4f..44cad0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,12 +54,14 @@ composer install --no-interaction --prefer-dist composer validate --strict composer audit --locked composer ci +node --check assets/js/admin-app-helpers.js node --check assets/js/admin-app-core.js node --check assets/js/admin-app-governance.js node --check assets/js/admin-app.js node --check assets/js/admin-notice-dismiss.js node --check tools/parse-plugin-check-output.mjs node --test tests/plugin-check-output.test.mjs +node --test tests/pdm-helpers.test.mjs ``` `composer ci` runs PHP lint and PHPUnit. The plugin supports PHP 8.0+, while PHPUnit 11 requires PHP 8.2+; CI therefore lints PHP 8.0-8.4 and runs unit tests on PHP 8.2-8.4. diff --git a/CITATION.cff b/CITATION.cff index 4f99374..3bc695d 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -7,8 +7,8 @@ authors: given-names: Michael url: "https://github.com/TheStreamCode/mikesoft-teamvault" repository-code: "https://github.com/TheStreamCode/mikesoft-teamvault" -version: "3.2.6" -date-released: "2026-08-08" +version: "3.3.0" +date-released: "2026-10-11" keywords: - WordPress - document management diff --git a/README.de.md b/README.de.md index bb97e11..2bb1659 100644 --- a/README.de.md +++ b/README.de.md @@ -11,7 +11,7 @@ Privater Dokumenten-Arbeitsbereich für WordPress-Teams, Agenturen und Betriebsabläufe, die eine kontrollierte Dateifreigabe außerhalb der Mediathek benötigen. -Aktuelle Plugin-Version: `3.2.6`. +Aktuelle Plugin-Version: `3.3.0`. Direkt über WordPress.org verfügbar und für aktuelle WordPress-Versionen gepflegt. @@ -61,7 +61,7 @@ Governance-Funktionen (alle kostenlos, seit 2.6): ## Neueste Version -Version `3.2.6` stärkt die Upload-Eingrenzung und die Zuverlässigkeit von ZIP-Exporten, korrigiert Randfälle bei REST-Governance-Einstellungen und der Multisite-Aktivierung und hält Werkzeugleistenaktionen in gängigen WordPress-Desktop-Layouts zugänglich. Außerdem verbessert sie die Präsentation und Governance des Repositorys. Konfigurationsänderungen sind nicht erforderlich. +Version `3.3.0` beschränkt die Verwaltung von Zugriffsregeln auf Administratoren oder Benutzer mit einer expliziten Verwaltungsregel, behebt ZIP-Exporte auf Servern, auf denen der temporäre Archivpfad nicht erstellt werden konnte, zeigt bei fehlgeschlagenen Aktionen die spezifischen Servermeldungen an und ergänzt Ordner in den Suchergebnissen. Konfigurationsänderungen sind nicht erforderlich. Version `3.2.5` schließt die in 3.2.4 begonnene Ausgabekodierung ab: Das Detailfenster maskiert die gespeicherte Dateiendung nun vor der Darstellung. Der Wert ist durch die Upload- und Reindex-Validierung bereits auf `[a-z0-9]` beschränkt; es handelt sich also um Verteidigung in der Tiefe und nicht um die Behebung einer ausnutzbaren Schwachstelle. Konfigurationsänderungen sind nicht erforderlich. diff --git a/README.es.md b/README.es.md index a39ebe5..6fd6e2c 100644 --- a/README.es.md +++ b/README.es.md @@ -11,7 +11,7 @@ Espacio de trabajo de documentos privados para equipos, agencias y operaciones de WordPress que necesitan compartir archivos de forma controlada fuera de la Biblioteca de medios. -Versión actual del plugin: `3.2.6`. +Versión actual del plugin: `3.3.0`. Disponible directamente en WordPress.org y mantenido para las versiones actuales de WordPress. @@ -61,7 +61,7 @@ Capacidades de gobernanza (todas gratuitas, desde la versión 2.6): ## Última versión -La versión `3.2.6` refuerza la contención de las subidas y la fiabilidad de las exportaciones ZIP, corrige casos límite en los ajustes de gobernanza REST y la activación multisitio, y mantiene accesibles las acciones de la barra de herramientas en los diseños de escritorio habituales de WordPress. También mejora la presentación y la gobernanza del repositorio. No se requieren cambios de configuración. +La versión `3.3.0` reserva la gestión de las reglas de permisos a los administradores o a los usuarios con una regla de gestión explícita, corrige las exportaciones ZIP en servidores donde no se podía crear la ruta temporal del archivo, muestra los mensajes específicos del servidor en las acciones fallidas y añade las carpetas a los resultados de búsqueda. No se requieren cambios de configuración. La versión `3.2.5` completa el trabajo de codificación de salida iniciado en la 3.2.4: el panel de detalles del archivo ahora escapa la extensión almacenada antes de mostrarla. El valor ya está limitado a `[a-z0-9]` por la validación de subida y reindexación, por lo que se trata de defensa en profundidad y no de la corrección de una vulnerabilidad explotable. No se requieren cambios de configuración. diff --git a/README.fr.md b/README.fr.md index 6066dd9..066bde6 100644 --- a/README.fr.md +++ b/README.fr.md @@ -11,7 +11,7 @@ Espace de travail documentaire privé pour les équipes, agences et services d'exploitation WordPress qui ont besoin d'un partage de fichiers contrôlé en dehors de la Médiathèque. -Version actuelle du plugin : `3.2.6`. +Version actuelle du plugin : `3.3.0`. Disponible directement sur WordPress.org et maintenu pour les versions actuelles de WordPress. @@ -61,7 +61,7 @@ Fonctionnalités de gouvernance (toutes gratuites, depuis la 2.6) : ## Dernière version -La version `3.2.6` renforce le confinement des téléversements et la fiabilité des exports ZIP, corrige des cas limites dans les réglages de gouvernance REST et l'activation multisite, et maintient les actions de la barre d'outils accessibles dans les dispositions de bureau WordPress courantes. Elle améliore également la présentation et la gouvernance du dépôt. Aucune modification de configuration n'est nécessaire. +La version `3.3.0` réserve la gestion des règles d'autorisation aux administrateurs ou aux utilisateurs disposant d'une règle de gestion explicite, corrige les exports ZIP sur les serveurs où le chemin temporaire de l'archive ne pouvait pas être créé, affiche les messages spécifiques du serveur en cas d'échec des actions et ajoute les dossiers aux résultats de recherche. Aucune modification de configuration n'est nécessaire. La version `3.2.5` achève le travail d'encodage des sorties entamé en 3.2.4 : le panneau de détails du fichier échappe désormais l'extension enregistrée avant de l'afficher. Cette valeur est déjà limitée à `[a-z0-9]` par la validation de téléversement et de réindexation ; il s'agit donc d'une défense en profondeur et non de la correction d'une faille exploitable. Aucune modification de configuration n'est nécessaire. diff --git a/README.it.md b/README.it.md index ad6af82..6996641 100644 --- a/README.it.md +++ b/README.it.md @@ -11,7 +11,7 @@ Spazio di lavoro documentale privato per team, agenzie e reparti operativi WordPress che hanno bisogno di condividere file in modo controllato al di fuori della Libreria Media. -Versione attuale del plugin: `3.2.6`. +Versione attuale del plugin: `3.3.0`. Disponibile direttamente su WordPress.org e mantenuto per le versioni correnti di WordPress. @@ -61,7 +61,7 @@ Funzionalità di governance (tutte gratuite, dalla versione 2.6): ## Ultima versione -La versione `3.2.6` rafforza il contenimento degli upload e l'affidabilità degli export ZIP, corregge casi limite nelle impostazioni di governance REST e nell'attivazione multisito, e mantiene accessibili le azioni della barra strumenti nei comuni layout desktop di WordPress. Migliora inoltre la presentazione e la governance del repository. Non sono richieste modifiche alla configurazione. +La versione `3.3.0` riserva la gestione delle regole di permesso agli amministratori o agli utenti con una regola di gestione esplicita, corregge gli export ZIP sui server in cui il percorso temporaneo dell'archivio non poteva essere creato, mostra i messaggi specifici del server per le azioni non riuscite e aggiunge le cartelle ai risultati di ricerca. Non sono richieste modifiche alla configurazione. La versione `3.2.5` completa il lavoro di codifica dell'output iniziato con la 3.2.4: il pannello dei dettagli file applica ora l'escaping all'estensione memorizzata prima di visualizzarla. Il valore è già limitato a `[a-z0-9]` dalla validazione di upload e reindicizzazione, quindi si tratta di difesa in profondità e non della correzione di una vulnerabilità sfruttabile. Non sono richieste modifiche alla configurazione. diff --git a/README.md b/README.md index 4706730..8d99d09 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ > [!NOTE] > This is the public source mirror for TeamVault. Installations and updates are distributed through [WordPress.org](https://wordpress.org/plugins/mikesoft-teamvault/); user support is handled in the [official support forum](https://wordpress.org/support/plugin/mikesoft-teamvault/). -Current plugin version: `3.2.6`. +Current plugin version: `3.3.0`. ![TeamVault file manager — folder tree, file cards with type-aware icons and image thumbnails, and the details panel with inline preview](.wordpress-org/assets/screenshot-1.jpg) @@ -65,7 +65,7 @@ All governance capabilities are included in the free plugin. ## Latest Release -Version `3.2.6` hardens upload containment and ZIP export reliability, corrects REST governance and multisite activation edge cases, and keeps toolbar actions accessible on common WordPress desktop layouts. It also refreshes repository presentation and release governance. No configuration changes are required. +Version `3.3.0` restricts permission-rule management to administrators or users with an explicit manage rule, fixes ZIP exports on servers where the temporary archive path could not be created, surfaces specific server messages on failed actions, and adds folder results to search. No configuration changes are required. See the [full changelog](changelog.txt) and [GitHub releases](https://github.com/TheStreamCode/mikesoft-teamvault/releases) for complete history. diff --git a/admin/views/logs-page.php b/admin/views/logs-page.php index fc47f90..0f2ecff 100644 --- a/admin/views/logs-page.php +++ b/admin/views/logs-page.php @@ -47,9 +47,15 @@ function mstv_get_log_target_label(string $targetType): string
0) : ?> - + - +
@@ -133,7 +139,13 @@ function mstv_get_log_target_label(string $targetType): string 1) : ?>
- +
@@ -145,7 +157,12 @@ function mstv_get_log_target_label(string $targetType): string - + diff --git a/admin/views/settings-page.php b/admin/views/settings-page.php index 96d79ff..6d55cfa 100644 --- a/admin/views/settings-page.php +++ b/admin/views/settings-page.php @@ -299,7 +299,7 @@ class="pdm-input"
- +
diff --git a/assets/js/admin-app-core.js b/assets/js/admin-app-core.js index 971aa1e..992060b 100644 --- a/assets/js/admin-app-core.js +++ b/assets/js/admin-app-core.js @@ -174,6 +174,7 @@ if (e.key === 'Escape') { this.closeMobilePanels(); this.elements.filtersDropdown?.classList.remove('active'); + this.hideContextMenu(); this.hideModal(); this.hidePreview(); this.hideUploadOverlay(); @@ -287,6 +288,18 @@ this.applyToolbarPermissions(); } } catch (error) { + // Do not leave the previous folder's items visible under the + // new folder heading when the request fails. + if (seq === this.state._loadSeq) { + this.state.folders = []; + this.state.files = []; + this.state.pagination = this.normalizePagination({ + page: 1, + per_page: this.state.pagination.perPage, + total_items: 0, + total_pages: 0, + }); + } this.showToast(mstvConfig.i18n.errorGeneric, 'error'); console.error('Load browser error:', error); } finally { @@ -865,7 +878,7 @@ const html = `
- - ${this.folderCan(folder, 'manage') ? ` + ${this.folderCanManageRules(folder) ? ` `; - this.showModal(mstvConfig.i18n.newFolder, html, footer); + this.showModal(title, html, footer); - const nameInput = document.getElementById('pdm-folder-name'); - nameInput?.select(); + const input = document.getElementById('pdm-text-prompt-input'); + input?.select(); this.elements.modal.querySelector('.pdm-modal-cancel')?.addEventListener('click', () => this.hideModal()); - this.elements.modal.querySelector('.pdm-modal-confirm')?.addEventListener('click', async () => { - const name = nameInput?.value?.trim(); - if (name) { - await this.createFolder(name); + this.bindModalConfirm(async () => { + const value = input?.value?.trim(); + if (value) { + await onConfirm(value); } }); }, + showNewFolderModal() { + this.showTextPromptModal({ + title: mstvConfig.i18n.newFolder, + initial: mstvConfig.i18n.untitledFolder, + onConfirm: name => this.createFolder(name) + }); + }, + showRenameFolderModal(folderId) { const folder = this.state.folders.find(f => f.id === folderId); if (!folder) return; - const html = ` -
- - -
- `; - - const footer = ` - - - `; - - this.showModal(mstvConfig.i18n.rename, html, footer); - - const nameInput = document.getElementById('pdm-folder-name'); - nameInput?.select(); - - this.elements.modal.querySelector('.pdm-modal-cancel')?.addEventListener('click', () => this.hideModal()); - this.elements.modal.querySelector('.pdm-modal-confirm')?.addEventListener('click', async () => { - const name = nameInput?.value?.trim(); - if (name) { - await this.renameFolder(folderId, name); - } + this.showTextPromptModal({ + title: mstvConfig.i18n.rename, + initial: folder.name, + onConfirm: name => this.renameFolder(folderId, name) }); }, @@ -1166,31 +1250,11 @@ const fallbackName = files.original_name ? files.original_name.replace(/\.[^/.]+$/, '') : ''; - const initialName = (files.display_name || fallbackName || '').trim(); - const html = ` -
- - -
- `; - - const footer = ` - - - `; - - this.showModal(mstvConfig.i18n.rename, html, footer); - - const nameInput = document.getElementById('pdm-files-name'); - nameInput?.select(); - - this.elements.modal.querySelector('.pdm-modal-cancel')?.addEventListener('click', () => this.hideModal()); - this.elements.modal.querySelector('.pdm-modal-confirm')?.addEventListener('click', async () => { - const name = nameInput?.value?.trim(); - if (name) { - await this.renameFile(fileId, name); - } + this.showTextPromptModal({ + title: mstvConfig.i18n.rename, + initial: (files.display_name || fallbackName || '').trim(), + onConfirm: name => this.renameFile(fileId, name) }); }, @@ -1239,7 +1303,7 @@ }); this.elements.modal.querySelector('.pdm-modal-cancel')?.addEventListener('click', () => this.hideModal()); - this.elements.modal.querySelector('.pdm-modal-confirm')?.addEventListener('click', async () => { + this.bindModalConfirm(async () => { await this.moveFile(fileId, selectedFolderId); }); }, @@ -1291,7 +1355,7 @@ }); this.elements.modal.querySelector('.pdm-modal-cancel')?.addEventListener('click', () => this.hideModal()); - this.elements.modal.querySelector('.pdm-modal-confirm')?.addEventListener('click', async () => { + this.bindModalConfirm(async () => { await this.moveFolder(folderId, selectedParentId); }); }, @@ -1340,7 +1404,7 @@ this.showToast(response.message || mstvConfig.i18n.folderCreateError, 'error'); } } catch (error) { - this.showToast(error.message || mstvConfig.i18n.folderCreateError, 'error'); + this.showToast(this.apiErrorMessage(error, 'folderCreateError'), 'error'); console.error('Create folder error:', error); } }, @@ -1357,7 +1421,7 @@ this.showToast(response.message || mstvConfig.i18n.renameError, 'error'); } } catch (error) { - this.showToast(mstvConfig.i18n.renameError, 'error'); + this.showToast(this.apiErrorMessage(error, 'renameError'), 'error'); console.error('Rename folder error:', error); } }, @@ -1379,7 +1443,7 @@ this.showToast(response.message || mstvConfig.i18n.deleteError, 'error'); } } catch (error) { - this.showToast(mstvConfig.i18n.deleteError, 'error'); + this.showToast(this.apiErrorMessage(error, 'deleteError'), 'error'); console.error('Delete folder error:', error); } }, @@ -1396,7 +1460,7 @@ this.showToast(response.message || mstvConfig.i18n.renameError, 'error'); } } catch (error) { - this.showToast(mstvConfig.i18n.renameError, 'error'); + this.showToast(this.apiErrorMessage(error, 'renameError'), 'error'); console.error('Rename files error:', error); } }, @@ -1419,7 +1483,7 @@ this.showToast(response.message || mstvConfig.i18n.deleteError, 'error'); } } catch (error) { - this.showToast(mstvConfig.i18n.deleteError, 'error'); + this.showToast(this.apiErrorMessage(error, 'deleteError'), 'error'); console.error('Delete files error:', error); } }, @@ -1443,7 +1507,7 @@ this.showToast(response.message || mstvConfig.i18n.moveError, 'error'); } } catch (error) { - this.showToast(mstvConfig.i18n.moveError, 'error'); + this.showToast(this.apiErrorMessage(error, 'moveError'), 'error'); console.error('Move files error:', error); } }, @@ -1467,7 +1531,7 @@ this.showToast(response.message || mstvConfig.i18n.folderMoveError, 'error'); } } catch (error) { - this.showToast(mstvConfig.i18n.folderMoveError, 'error'); + this.showToast(this.apiErrorMessage(error, 'folderMoveError'), 'error'); console.error('Move folder error:', error); } }, @@ -1532,7 +1596,9 @@ this.elements.previewContainer.appendChild(img); } - this.elements.previewDownload.onclick = () => this.downloadFile(fileId); + if (this.elements.previewDownload) { + this.elements.previewDownload.onclick = () => this.downloadFile(fileId); + } this.elements.previewModal.classList.add('active'); }, @@ -1576,6 +1642,10 @@ this.state.folders = response.data.folders; this.state.files = response.data.files; this.state.pagination = this.normalizePagination(response.data.pagination); + // Search results span folders; the previous folder's + // permission map must not keep gating the toolbar. + this.state.permissions = null; + this.applyToolbarPermissions(); this.syncPerPageSelect(); } } catch (error) { @@ -1635,7 +1705,9 @@ async handleFileSelect(files) { if (!files || files.length === 0) return; + if (this.state.isUploading) return; + this.state.isUploading = true; this.hideUploadOverlay(); const fileList = Array.from(files); @@ -1655,6 +1727,7 @@ } } finally { this.hideUploadProgress(); + this.state.isUploading = false; } if (anySuccess) { @@ -1702,7 +1775,7 @@ if (error.name === 'AbortError') { return false; } - this.showToast(error.message || mstvConfig.i18n.uploadError, 'error'); + this.showToast(this.apiErrorMessage(error, 'uploadError'), 'error'); console.error('Upload error:', error); return false; } @@ -1962,45 +2035,28 @@ }, 5000); }, - showFolderContextMenu(e, folderId) { + /** + * Shared context-menu factory: renders the given items, clamps the menu + * inside the viewport, hides any previous menu, and dispatches clicks. + * Items are { key, label, icon, className?, onClick? } or { divider: true }. + */ + buildContextMenu(e, items) { this.hideContextMenu(); const menu = document.createElement('div'); menu.className = 'context-menu'; - menu.innerHTML = ` -
- - - - ${mstvConfig.i18n.folder} -
-
- - - - - ${mstvConfig.i18n.rename} -
-
- - - - - - - - - ${mstvConfig.i18n.move} -
-
-
- - - - - ${mstvConfig.i18n.delete} -
- `; + menu.innerHTML = items.map(item => { + if (item.divider) { + return '
'; + } + + return ` +
+ ${item.icon} + ${item.label} +
+ `; + }).join(''); menu.style.left = `${e.pageX}px`; menu.style.top = `${e.pageY}px`; @@ -2014,125 +2070,117 @@ menu.style.top = `${window.innerHeight - rect.height - 10}px`; } - menu.querySelector('.pdm-context-open')?.addEventListener('click', () => { - this.hideContextMenu(); - this.loadBrowser(folderId, 1, { clearSearchInput: true }); - }); + items.forEach(item => { + if (item.divider || !item.onClick) { + return; + } - menu.querySelector('.pdm-context-rename')?.addEventListener('click', () => { - this.hideContextMenu(); - this.showRenameFolderModal(folderId); + menu.querySelector(`[data-context-action="${item.key}"]`)?.addEventListener('click', () => { + this.hideContextMenu(); + item.onClick(); + }); }); - menu.querySelector('.pdm-context-move')?.addEventListener('click', () => { - this.hideContextMenu(); - this.showMoveFolderModal(folderId); - }); + document.addEventListener('click', this._boundHideContextMenu); + }, - menu.querySelector('.pdm-context-delete')?.addEventListener('click', () => { - this.hideContextMenu(); - this.deleteFolder(folderId); - }); + contextMenuIcon(name) { + const icons = { + open: '', + preview: '', + download: '', + rename: '', + move: '', + delete: '' + }; - document.addEventListener('click', this._boundHideContextMenu); + return icons[name] || ''; }, - showFileContextMenu(e, fileId) { - this.hideContextMenu(); + showFolderContextMenu(e, folderId) { + const icon = name => this.contextMenuIcon(name); + + this.buildContextMenu(e, [ + { + key: 'open', + label: mstvConfig.i18n.folder, + icon: icon('open'), + onClick: () => this.loadBrowser(folderId, 1, { clearSearchInput: true }) + }, + { + key: 'rename', + label: mstvConfig.i18n.rename, + icon: icon('rename'), + onClick: () => this.showRenameFolderModal(folderId) + }, + { + key: 'move', + label: mstvConfig.i18n.move, + icon: icon('move'), + onClick: () => this.showMoveFolderModal(folderId) + }, + { divider: true }, + { + key: 'delete', + label: mstvConfig.i18n.delete, + icon: icon('delete'), + className: 'context-menu-item--danger', + onClick: () => this.deleteFolder(folderId) + } + ]); + }, + showFileContextMenu(e, fileId) { const files = this.state.files.find(f => f.id === fileId); if (!files) return; - const isAvailable = this.isFileAvailable(files); - const menu = document.createElement('div'); - menu.className = 'context-menu'; - menu.innerHTML = ` - ${files.is_previewable ? ` -
- - - - - ${mstvConfig.i18n.preview} -
- ` : ''} -
- - - - - - ${mstvConfig.i18n.download} -
-
- - - - - ${mstvConfig.i18n.rename} -
-
- - - - - - - - - ${mstvConfig.i18n.move} -
-
-
- - - - - ${mstvConfig.i18n.delete} -
- `; - - menu.style.left = `${e.pageX}px`; - menu.style.top = `${e.pageY}px`; - document.body.appendChild(menu); - - const rect = menu.getBoundingClientRect(); - if (rect.right > window.innerWidth) { - menu.style.left = `${window.innerWidth - rect.width - 10}px`; - } - if (rect.bottom > window.innerHeight) { - menu.style.top = `${window.innerHeight - rect.height - 10}px`; + const isAvailable = this.isFileAvailable(files); + const icon = name => this.contextMenuIcon(name); + const items = []; + + if (files.is_previewable) { + items.push({ + key: 'preview', + label: mstvConfig.i18n.preview, + icon: icon('preview'), + onClick: () => this.previewFile(fileId) + }); } - menu.querySelector('.pdm-context-preview')?.addEventListener('click', () => { - this.hideContextMenu(); - this.previewFile(fileId); - }); - - menu.querySelector('.pdm-context-download')?.addEventListener('click', () => { - this.hideContextMenu(); - this.downloadFile(fileId); - }); - - menu.querySelector('.pdm-context-rename')?.addEventListener('click', () => { - this.hideContextMenu(); - this.showRenameFileModal(fileId); - }); - - menu.querySelector('.pdm-context-move')?.addEventListener('click', () => { - this.hideContextMenu(); - this.showMoveFileModal(fileId); - }); - - menu.querySelector('.pdm-context-delete')?.addEventListener('click', () => { - this.hideContextMenu(); - this.deleteFile(fileId); - }); + items.push( + { + key: 'download', + label: mstvConfig.i18n.download, + icon: icon('download'), + className: isAvailable ? '' : 'context-menu-item--disabled', + onClick: isAvailable ? () => this.downloadFile(fileId) : null + }, + { + key: 'rename', + label: mstvConfig.i18n.rename, + icon: icon('rename'), + onClick: () => this.showRenameFileModal(fileId) + }, + { + key: 'move', + label: mstvConfig.i18n.move, + icon: icon('move'), + onClick: () => this.showMoveFileModal(fileId) + }, + { divider: true }, + { + key: 'delete', + label: mstvConfig.i18n.delete, + icon: icon('delete'), + className: 'context-menu-item--danger', + onClick: () => this.deleteFile(fileId) + } + ); - document.addEventListener('click', this._boundHideContextMenu); + this.buildContextMenu(e, items); }, - hideContextMenu() { + hideContextMenu() { document.querySelectorAll('.context-menu').forEach(menu => menu.remove()); document.removeEventListener('click', this._boundHideContextMenu); }, @@ -2348,6 +2396,28 @@ return !perms || perms[action] !== false; }, + folderCanManageRules(folder) { + const perms = folder && folder.permissions; + if (perms && typeof perms.can_manage_rules === 'boolean') { + return perms.can_manage_rules; + } + return this.folderCan(folder, 'manage'); + }, + + apiErrorMessage(error, i18nKey) { + // Only server-provided API messages reach the user; network and + // programming errors fall back to the localized generic string. + if (error && error.isApiError) { + if (error.code === 'rest_cookie_invalid_nonce' && mstvConfig.i18n.sessionExpired) { + return mstvConfig.i18n.sessionExpired; + } + if (typeof error.message === 'string' && error.message) { + return error.message; + } + } + return mstvConfig.i18n[i18nKey] || mstvConfig.i18n.errorGeneric; + }, + applyToolbarPermissions() { const toggle = (el, allowed) => { if (!el) return; diff --git a/assets/js/admin-app-governance.js b/assets/js/admin-app-governance.js index 0ab2d13..e1fb4ba 100644 --- a/assets/js/admin-app-governance.js +++ b/assets/js/admin-app-governance.js @@ -18,7 +18,7 @@ manage: i18n.permManage, }; - const head = p.actions.map(a => `${actionLabels[a] || a}`).join(''); + const head = p.actions.map(a => `${this.escapeHtml(actionLabels[a] || a)}`).join(''); const rows = p.rules.length ? p.rules.map((rule, idx) => { const cells = p.actions.map(a => ` @@ -115,24 +115,12 @@ groupBox?.classList.toggle('pdm-is-hidden', !isGroup); }); - const userSearch = modal.querySelector('.pdm-perm-user-search'); - const results = modal.querySelector('.pdm-perm-user-results'); - const runSearch = this.debounce(async () => { - const q = userSearch.value.trim(); - if (q.length < 2) { results.innerHTML = ''; return; } - try { - const res = await this.apiGet(`users/search?q=${encodeURIComponent(q)}`); - results.innerHTML = (res.data || []).map(u => - `` - ).join(''); - results.querySelectorAll('.pdm-perm-user-option').forEach(opt => { - opt.addEventListener('click', () => { - this.addPermissionPrincipal('user', parseInt(opt.dataset.id, 10), opt.dataset.label); - }); - }); - } catch (e) { /* ignore search errors */ } - }, 300); - userSearch?.addEventListener('input', runSearch); + this.bindUserSearch({ + input: modal.querySelector('.pdm-perm-user-search'), + results: modal.querySelector('.pdm-perm-user-results'), + render: user => ``, + onPick: (id, label) => this.addPermissionPrincipal('user', id, label) + }); modal.querySelector('.pdm-perm-add-group-btn')?.addEventListener('click', () => { const sel = modal.querySelector('.pdm-perm-group-select'); @@ -175,7 +163,7 @@ this.showToast(mstvConfig.i18n.errorGeneric, 'error'); } } catch (e) { - this.showToast(e.message || mstvConfig.i18n.errorGeneric, 'error'); + this.showToast(this.apiErrorMessage(e, 'errorGeneric'), 'error'); } }, @@ -192,7 +180,7 @@ this.showToast(mstvConfig.i18n.errorGeneric, 'error'); } } catch (e) { - this.showToast(e.message || mstvConfig.i18n.errorGeneric, 'error'); + this.showToast(this.apiErrorMessage(e, 'errorGeneric'), 'error'); } }, @@ -218,7 +206,7 @@

-
@@ -327,30 +315,19 @@ modal.querySelector('.pdm-modal-cancel')?.addEventListener('click', () => this.hideModal()); this.renderGroupChips(); - const search = modal.querySelector('.mstv-group-user-search'); - const results = modal.querySelector('.mstv-group-user-results'); - const run = this.debounce(async () => { - const q = search.value.trim(); - if (q.length < 2) { results.innerHTML = ''; return; } - try { - const res = await this.apiGet(`users/search?q=${encodeURIComponent(q)}`); - results.innerHTML = (res.data || []).map(u => - `` - ).join(''); - results.querySelectorAll('.pdm-perm-user-option').forEach(opt => { - opt.addEventListener('click', () => { - const id = parseInt(opt.dataset.id, 10); - if (!this._groupMembers.some(m => m.id === id)) { - this._groupMembers.push({ id, display_name: opt.dataset.label }); - this.renderGroupChips(); - } - search.value = ''; - results.innerHTML = ''; - }); - }); - } catch (e) { /* ignore */ } - }, 300); - search?.addEventListener('input', run); + this.bindUserSearch({ + input: modal.querySelector('.mstv-group-user-search'), + results: modal.querySelector('.mstv-group-user-results'), + render: user => ``, + onPick: (id, label, option, ctx) => { + if (!this._groupMembers.some(m => m.id === id)) { + this._groupMembers.push({ id, display_name: label }); + this.renderGroupChips(); + } + ctx.input.value = ''; + ctx.results.innerHTML = ''; + } + }); modal.querySelector('.mstv-group-save')?.addEventListener('click', () => this.saveGroup(group ? group.id : null)); }, @@ -359,7 +336,7 @@ const wrap = this.elements.modal.querySelector('.mstv-group-chips'); if (!wrap) return; wrap.innerHTML = this._groupMembers.map(m => - `${this.escapeHtml(m.display_name)} ` + `${this.escapeHtml(m.display_name)} ` ).join(''); wrap.querySelectorAll('.mstv-chip-remove').forEach(btn => { btn.addEventListener('click', () => { @@ -390,7 +367,7 @@ this.showToast(mstvConfig.i18n.errorGeneric, 'error'); } } catch (e) { - this.showToast(e.message || mstvConfig.i18n.errorGeneric, 'error'); + this.showToast(this.apiErrorMessage(e, 'errorGeneric'), 'error'); } }, @@ -406,7 +383,7 @@ this.showToast(mstvConfig.i18n.errorGeneric, 'error'); } } catch (e) { - this.showToast(e.message || mstvConfig.i18n.errorGeneric, 'error'); + this.showToast(this.apiErrorMessage(e, 'errorGeneric'), 'error'); } }, @@ -502,22 +479,12 @@ root.querySelector('.mstv-quota-group-box').classList.toggle('pdm-is-hidden', !isGroup); }); - const search = root.querySelector('.mstv-quota-user-search'); - const results = root.querySelector('.mstv-quota-user-results'); - const run = this.debounce(async () => { - const term = search.value.trim(); - if (term.length < 2) { results.innerHTML = ''; return; } - try { - const res = await this.apiGet(`users/search?q=${encodeURIComponent(term)}`); - results.innerHTML = (res.data || []).map(u => - `` - ).join(''); - results.querySelectorAll('.pdm-perm-user-option').forEach(opt => { - opt.addEventListener('click', () => this.addQuotaItem('user', parseInt(opt.dataset.id, 10), opt.dataset.label)); - }); - } catch (e) { /* ignore */ } - }, 300); - search.addEventListener('input', run); + this.bindUserSearch({ + input: root.querySelector('.mstv-quota-user-search'), + results: root.querySelector('.mstv-quota-user-results'), + render: user => ``, + onPick: (id, label) => this.addQuotaItem('user', id, label) + }); root.querySelector('.mstv-quota-add-group-btn')?.addEventListener('click', () => { const sel = root.querySelector('.mstv-quota-group-select'); @@ -555,7 +522,7 @@ this.showToast(mstvConfig.i18n.errorGeneric, 'error'); } } catch (e) { - this.showToast(e.message || mstvConfig.i18n.errorGeneric, 'error'); + this.showToast(this.apiErrorMessage(e, 'errorGeneric'), 'error'); } }, @@ -633,8 +600,10 @@ }); if (f.date_from) params.set('date_from', f.date_from); if (f.date_to) params.set('date_to', f.date_to); - // The report "action" select is preview/download; pass it through as a log action filter. - if (f.action) params.set('action', f.action); + // The report "action" select is preview/download; pass it through as + // a log action filter. It must not collide with the admin-post routing + // parameter, so it travels under a dedicated key. + if (f.action) params.set('log_action', f.action); const url = new URL(actionUrl); params.forEach((value, key) => url.searchParams.set(key, value)); window.location.href = url.toString(); @@ -668,8 +637,8 @@ const eventBoxes = n.available_events.map(ev => ` `).join(''); const groupBoxes = (n.groups || []).map(g => ` @@ -733,27 +702,19 @@ this.renderNotifChips(); - const search = root.querySelector('.mstv-notif-user-search'); - const results = root.querySelector('.mstv-notif-user-results'); - const run = this.debounce(async () => { - const term = search.value.trim(); - if (term.length < 2) { results.innerHTML = ''; return; } - try { - const res = await this.apiGet(`users/search?q=${encodeURIComponent(term)}`); - results.innerHTML = (res.data || []).map(u => - `` - ).join(''); - results.querySelectorAll('.pdm-perm-user-option').forEach(opt => opt.addEventListener('click', () => { - const id = parseInt(opt.dataset.id, 10); - if (!n.recipients.users.some(u => u.id === id)) { - n.recipients.users.push({ id, display_name: opt.dataset.label }); - this.renderNotifChips(); - } - search.value = ''; results.innerHTML = ''; - })); - } catch (e) { /* ignore */ } - }, 300); - search.addEventListener('input', run); + this.bindUserSearch({ + input: root.querySelector('.mstv-notif-user-search'), + results: root.querySelector('.mstv-notif-user-results'), + render: user => ``, + onPick: (id, label, option, ctx) => { + if (!n.recipients.users.some(u => u.id === id)) { + n.recipients.users.push({ id, display_name: label }); + this.renderNotifChips(); + } + ctx.input.value = ''; + ctx.results.innerHTML = ''; + } + }); root.querySelector('.mstv-notif-save').addEventListener('click', () => this.saveNotifications()); }, @@ -762,7 +723,7 @@ const wrap = this._notifRoot.querySelector('.mstv-notif-chips'); const n = this._notif; wrap.innerHTML = n.recipients.users.map(u => - `${this.escapeHtml(u.display_name)} ` + `${this.escapeHtml(u.display_name)} ` ).join(''); wrap.querySelectorAll('.mstv-chip-remove').forEach(btn => btn.addEventListener('click', () => { const id = parseInt(btn.dataset.id, 10); @@ -790,7 +751,7 @@ this.showToast(mstvConfig.i18n.errorGeneric, 'error'); } } catch (e) { - this.showToast(e.message || mstvConfig.i18n.errorGeneric, 'error'); + this.showToast(this.apiErrorMessage(e, 'errorGeneric'), 'error'); } }, @@ -800,7 +761,10 @@ if (contentType.includes('application/json')) { const data = await response.json(); if (!response.ok && !data.success) { - throw new Error(data.message || `HTTP ${response.status}`); + throw Object.assign( + new Error(data.message || `HTTP ${response.status}`), + { isApiError: true, code: data.code || '' } + ); } return data; } @@ -811,7 +775,10 @@ .replace(/\s+/g, ' ') .trim(); - throw new Error(message || `HTTP ${response.status}`); + throw Object.assign( + new Error(message || `HTTP ${response.status}`), + { isApiError: true } + ); }, }); })(); diff --git a/assets/js/admin-app-helpers.js b/assets/js/admin-app-helpers.js new file mode 100644 index 0000000..28e9c66 --- /dev/null +++ b/assets/js/admin-app-helpers.js @@ -0,0 +1,67 @@ +/** + * Pure admin-app helpers for Mikesoft TeamVault. + * + * This file must stay dependency-free (no PDM object, no fetch, no DOM) so the + * functions are unit-testable with `node --test tests/pdm-helpers.test.mjs`. + * Impure values (REST base URL, current location) are passed in by callers; + * when omitted they fall back to the localized runtime configuration. + */ +(function (window) { + 'use strict'; + + const PDMHelpers = { + escapeHtml(text) { + const characters = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + + return String(text ?? '').replace(/[&<>"']/g, character => characters[character]); + }, + + buildApiUrl(endpoint, restUrl) { + const configuredBase = restUrl === undefined && typeof window.mstvConfig === 'object' + ? window.mstvConfig.restUrl + : restUrl; + + const base = String(configuredBase || '').replace(/\/+$/, ''); + const path = String(endpoint || '').replace(/^\/+/, ''); + + if (base.includes('?')) { + const queryIndex = path.indexOf('?'); + + if (queryIndex === -1) { + return `${base}/${path}`; + } + + return `${base}/${path.substring(0, queryIndex)}&${path.substring(queryIndex + 1)}`; + } + + return `${base}/${path}`; + }, + + normalizeSameOriginUrl(value, href) { + const baseHref = href === undefined && typeof window.location === 'object' + ? window.location.href + : href; + + try { + const url = new URL(String(value || ''), baseHref); + const baseOrigin = new URL(baseHref).origin; + + if (!['http:', 'https:'].includes(url.protocol) || url.origin !== baseOrigin) { + return ''; + } + + return url.toString(); + } catch (error) { + return ''; + } + } + }; + + window.PDMHelpers = PDMHelpers; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/assets/js/admin-app.js b/assets/js/admin-app.js index ddae970..8e0a7f4 100644 --- a/assets/js/admin-app.js +++ b/assets/js/admin-app.js @@ -7,20 +7,7 @@ Object.assign(window.PDM, { buildApiUrl(endpoint) { - const base = String(mstvConfig.restUrl || '').replace(/\/+$/, ''); - const path = String(endpoint || '').replace(/^\/+/, ''); - - if (base.includes('?')) { - const qIdx = path.indexOf('?'); - - if (qIdx === -1) { - return `${base}/${path}`; - } - - return `${base}/${path.substring(0, qIdx)}&${path.substring(qIdx + 1)}`; - } - - return `${base}/${path}`; + return window.PDMHelpers.buildApiUrl(endpoint, mstvConfig.restUrl); }, buildFreshApiUrl(endpoint) { @@ -30,29 +17,11 @@ }, escapeHtml(text) { - const characters = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - }; - - return String(text ?? '').replace(/[&<>"']/g, character => characters[character]); + return window.PDMHelpers.escapeHtml(text); }, normalizeSameOriginUrl(value) { - try { - const url = new URL(String(value || ''), window.location.href); - - if (!['http:', 'https:'].includes(url.protocol) || url.origin !== window.location.origin) { - return ''; - } - - return url.toString(); - } catch (error) { - return ''; - } + return window.PDMHelpers.normalizeSameOriginUrl(value, window.location.href); }, formatBytes(bytes) { @@ -100,55 +69,31 @@ } if (userSearch && userResults) { - userSearch.addEventListener('input', this.debounce(async (e) => { - const query = e.target.value.trim(); - - if (query.length < 2) { - userResults.classList.remove('active'); - userResults.innerHTML = ''; - return; - } - - try { - const response = await this.apiGet(`users/search?q=${encodeURIComponent(query)}`); - - if (response.success && response.data.length > 0) { - userResults.innerHTML = response.data.map(user => ` -
-
${this.escapeHtml(user.display_name.charAt(0).toUpperCase())}
-
-
${this.escapeHtml(user.display_name)}
- -
-
- `).join(''); - userResults.classList.add('active'); - } else { - userResults.innerHTML = `
${mstvConfig.i18n.userNotFound}
`; - userResults.classList.add('active'); + this.bindUserSearch({ + input: userSearch, + results: userResults, + render: user => ` +
+
${this.escapeHtml(user.display_name.charAt(0).toUpperCase())}
+
+
${this.escapeHtml(user.display_name)}
+ +
+
+ `, + renderEmpty: () => `
${mstvConfig.i18n.userNotFound}
`, + onShown: shown => userResults.classList.toggle('active', shown), + onPick: (id, label, option) => { + if (allowedUsersContainer.querySelector(`[data-user-id="${id}"]`)) { + alert(mstvConfig.i18n.userAlreadyInList); + return; } - } catch (error) { - console.error('User search error:', error); - } - }, 300)); - - userResults.addEventListener('click', (e) => { - const result = e.target.closest('.pdm-user-result'); - if (!result) return; - const userId = result.dataset.userId; - const userLogin = result.dataset.userLogin; - const userName = result.dataset.userName; - - if (allowedUsersContainer.querySelector(`[data-user-id="${userId}"]`)) { - alert(mstvConfig.i18n.userAlreadyInList); - return; + this.addUserTag(id, label, option.dataset.userLogin || ''); + userSearch.value = ''; + userResults.classList.remove('active'); + userResults.innerHTML = ''; } - - this.addUserTag(userId, userName, userLogin); - userSearch.value = ''; - userResults.classList.remove('active'); - userResults.innerHTML = ''; }); document.addEventListener('click', (e) => { @@ -172,7 +117,8 @@ } }, - addUserTag(userId, userName, userLogin) { + addUserTag(rawUserId, userName, userLogin) { + const userId = parseInt(rawUserId, 10); const allowedUsersContainer = document.getElementById('pdm-allowed-users'); const noUsersMsg = document.getElementById('pdm-no-users'); @@ -183,7 +129,7 @@ tag.dataset.userId = userId; tag.innerHTML = ` ${this.escapeHtml(userName)} (${this.escapeHtml(userLogin)}) -