Browse Source

WIP new WebAdmin: folders page

Signed-off-by: Nicola Murino <[email protected]>
Nicola Murino 1 year ago
parent
commit
0722c4369b

+ 4 - 2
internal/httpd/server.go

@@ -1682,7 +1682,7 @@ func (s *httpdServer) setupWebAdminRoutes() {
 
 			router.With(s.checkPerm(dataprovider.PermAdminViewUsers), s.refreshCookie).
 				Get(webUsersPath, s.handleGetWebUsers)
-			router.With(s.checkPerm(dataprovider.PermAdminViewUsers), s.refreshCookie).
+			router.With(s.checkPerm(dataprovider.PermAdminViewUsers), compressor.Handler, s.refreshCookie).
 				Get(webUsersPath+"/json", getAllUsers)
 			router.With(s.checkPerm(dataprovider.PermAdminAddUsers), s.refreshCookie).
 				Get(webUserPath, s.handleWebAddUserGet)
@@ -1693,7 +1693,7 @@ func (s *httpdServer) setupWebAdminRoutes() {
 				s.handleWebUpdateUserPost)
 			router.With(s.checkPerm(dataprovider.PermAdminManageGroups), s.refreshCookie).
 				Get(webGroupsPath, s.handleWebGetGroups)
-			router.With(s.checkPerm(dataprovider.PermAdminManageGroups), s.refreshCookie).
+			router.With(s.checkPerm(dataprovider.PermAdminManageGroups), compressor.Handler, s.refreshCookie).
 				Get(webGroupsPath+"/json", getAllGroups)
 			router.With(s.checkPerm(dataprovider.PermAdminManageGroups), s.refreshCookie).
 				Get(webGroupPath, s.handleWebAddGroupGet)
@@ -1708,6 +1708,8 @@ func (s *httpdServer) setupWebAdminRoutes() {
 				Get(webConnectionsPath, s.handleWebGetConnections)
 			router.With(s.checkPerm(dataprovider.PermAdminManageFolders), s.refreshCookie).
 				Get(webFoldersPath, s.handleWebGetFolders)
+			router.With(s.checkPerm(dataprovider.PermAdminManageFolders), compressor.Handler, s.refreshCookie).
+				Get(webFoldersPath+"/json", getAllFolders)
 			router.With(s.checkPerm(dataprovider.PermAdminManageFolders), s.refreshCookie).
 				Get(webFolderPath, s.handleWebAddFolderGet)
 			router.With(s.checkPerm(dataprovider.PermAdminManageFolders)).Post(webFolderPath, s.handleWebAddFolderPost)

+ 18 - 21
internal/httpd/webadmin.go

@@ -172,11 +172,6 @@ type adminsPage struct {
 	Admins []dataprovider.Admin
 }
 
-type foldersPage struct {
-	basePage
-	Folders []vfs.BaseVirtualFolder
-}
-
 type rolesPage struct {
 	basePage
 	Roles []dataprovider.Role
@@ -428,7 +423,7 @@ func loadAdminTemplates(templatesPath string) {
 		filepath.Join(templatesPath, templateAdminDir, templateMessage),
 	}
 	foldersPaths := []string{
-		filepath.Join(templatesPath, templateCommonDir, templateCommonCSS),
+		filepath.Join(templatesPath, templateCommonDir, templateCommonBase),
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
 		filepath.Join(templatesPath, templateAdminDir, templateFolders),
 	}
@@ -3444,7 +3439,7 @@ func (s *httpdServer) handleWebUpdateFolderPost(w http.ResponseWriter, r *http.R
 }
 
 func (s *httpdServer) getWebVirtualFolders(w http.ResponseWriter, r *http.Request, limit int, minimal bool) ([]vfs.BaseVirtualFolder, error) {
-	folders := make([]vfs.BaseVirtualFolder, 0, limit)
+	folders := make([]vfs.BaseVirtualFolder, 0, 50)
 	for {
 		f, err := dataprovider.GetFolders(limit, len(folders), dataprovider.OrderASC, minimal)
 		if err != nil {
@@ -3459,25 +3454,27 @@ func (s *httpdServer) getWebVirtualFolders(w http.ResponseWriter, r *http.Reques
 	return folders, nil
 }
 
-func (s *httpdServer) handleWebGetFolders(w http.ResponseWriter, r *http.Request) {
+func getAllFolders(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
-	limit := defaultQueryLimit
-	if _, ok := r.URL.Query()["qlimit"]; ok {
-		var err error
-		limit, err = strconv.Atoi(r.URL.Query().Get("qlimit"))
+	folders := make([]vfs.BaseVirtualFolder, 0, 50)
+	for {
+		f, err := dataprovider.GetFolders(defaultQueryLimit, len(folders), dataprovider.OrderASC, false)
 		if err != nil {
-			limit = defaultQueryLimit
+			sendAPIResponse(w, r, err, getI18NErrorString(err, util.I18nError500Message), http.StatusInternalServerError)
+			return
+		}
+		folders = append(folders, f...)
+		if len(f) < defaultQueryLimit {
+			break
 		}
 	}
-	folders, err := s.getWebVirtualFolders(w, r, limit, false)
-	if err != nil {
-		return
-	}
+	render.JSON(w, r, folders)
+}
 
-	data := foldersPage{
-		basePage: s.getBasePageData(util.I18nFoldersTitle, webFoldersPath, r),
-		Folders:  folders,
-	}
+func (s *httpdServer) handleWebGetFolders(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+
+	data := s.getBasePageData(util.I18nFoldersTitle, webFoldersPath, r)
 	renderAdminTemplate(w, templateFolders, data)
 }
 

+ 5 - 2
static/locales/en/translation.json

@@ -206,7 +206,8 @@
         "zero_no_limit_help": "0 means no limit",
         "global_settings": "Global settings",
         "mandatory_encryption": "Mandatory encryption",
-        "name_invalid": "The specified username is not valid, the following characters are allowed: a-zA-Z0-9-_.~"
+        "name_invalid": "The specified username is not valid, the following characters are allowed: a-zA-Z0-9-_.~",
+        "associations": "Associations"
     },
     "fs": {
         "view_file": "View file \"{{- path}}\"",
@@ -474,10 +475,12 @@
         "members_summary": "Users: {{users}}. Admins: {{admins}}"
     },
     "virtual_folders": {
+        "view_manage": "View and manage virtual folders",
         "mount_path": "mount path, i.e. /vfolder",
         "quota_size": "Quota size",
         "quota_size_help": "0 means no limit. You can use MB/GB/TB suffix",
-        "quota_files": "Quota files"
+        "quota_files": "Quota files",
+        "associations_summary": "Users: {{users}}. Groups: {{groups}}"
     },
     "storage": {
         "title": "File system",

+ 5 - 2
static/locales/it/translation.json

@@ -206,7 +206,8 @@
         "zero_no_limit_help": "0 significa nessun limite",
         "global_settings": "Impostazioni globali",
         "mandatory_encryption": "Crittografia obbligatoria",
-        "name_invalid": "Il nome specificato non è valido, sono consentiti i seguenti caratteri: a-zA-Z0-9-_.~"
+        "name_invalid": "Il nome specificato non è valido, sono consentiti i seguenti caratteri: a-zA-Z0-9-_.~",
+        "associations": "Associazioni"
     },
     "fs": {
         "view_file": "Visualizza file \"{{- path}}\"",
@@ -474,10 +475,12 @@
         "members_summary": "Utenti: {{users}}. Amministratori: {{admins}}"
     },
     "virtual_folders": {
+        "view_manage": "Visualizza e gestisci cartelle virtuali",
         "mount_path": "percorso, es. /vfolder",
         "quota_size": "Quota (dimensione)",
         "quota_size_help": "0 significa nessun limite. E' possibile utilizzare il suffisso MB/GB/TB",
-        "quota_files": "Quota (numero file)"
+        "quota_files": "Quota (numero file)",
+        "associations_summary": "Utenti: {{users}}. Gruppi: {{groups}}"
     },
     "storage": {
         "title": "File system",

+ 363 - 315
templates/webadmin/folders.html

@@ -1,361 +1,409 @@
 <!--
-Copyright (C) 2019 Nicola Murino
+Copyright (C) 2024 Nicola Murino
 
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as published
-by the Free Software Foundation, version 3.
+This WebUI uses the KeenThemes Mega Bundle, a proprietary theme:
 
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU Affero General Public License for more details.
+https://keenthemes.com/products/templates-mega-bundle
 
-You should have received a copy of the GNU Affero General Public License
-along with this program. If not, see <https://www.gnu.org/licenses/>.
+KeenThemes HTML/CSS/JS components are allowed for use only within the
+SFTPGo product and restricted to be used in a resealable HTML template
+that can compete with KeenThemes products anyhow.
+
+This WebUI is allowed for use only within the SFTPGo product and
+therefore cannot be used in derivative works/products without an
+explicit grant from the SFTPGo Team ([email protected]).
 -->
 {{template "base" .}}
 
-{{define "title"}}{{.Title}}{{end}}
-
-{{define "extra_css"}}
-<link href="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.css" rel="stylesheet">
-<link href="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.css" rel="stylesheet">
-<link href="{{.StaticURL}}/vendor/datatables/fixedHeader.bootstrap4.min.css" rel="stylesheet">
-<link href="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.css" rel="stylesheet">
-<link href="{{.StaticURL}}/vendor/datatables/select.bootstrap4.min.css" rel="stylesheet">
-<link href="{{.StaticURL}}/vendor/datatables/colReorder.bootstrap4.min.css" rel="stylesheet">
-{{end}}
+{{- define "extra_css"}}
+<link href="{{.StaticURL}}/assets/plugins/custom/datatables/datatables.bundle.css" rel="stylesheet" type="text/css"/>
+{{- end}}
 
-{{define "page_body"}}
-<div id="errorMsg" class="alert alert-warning fade show" style="display: none;" role="alert">
-    <span id="errorTxt"></span>
-    <button type="button" class="close" aria-label="Close" onclick="dismissErrorMsg();">
-      <span aria-hidden="true">&times;</span>
-    </button>
-</div>
-<script type="text/javascript">
-    function dismissErrorMsg(){
-        $('#errorMsg').hide();
-    }
-</script>
+{{- define "page_body"}}
+{{- template "errmsg" ""}}
+<div class="card shadow-sm">
+    <div class="card-header bg-light">
+        <h3 data-i18n="virtual_folders.view_manage" class="card-title section-title">View and manage folders</h3>
+    </div>
+    <div id="card_body" class="card-body">
+        <div id="loader" class="align-items-center text-center my-10">
+            <span class="spinner-border w-15px h-15px text-muted align-middle me-2"></span>
+            <span data-i18n="general.loading" class="text-gray-700">Loading...</span>
+        </div>
+        <div id="card_content" class="d-none">
+            <div class="d-flex flex-stack flex-wrap mb-5">
+                <div class="d-flex align-items-center position-relative my-2">
+                    <i class="ki-solid ki-magnifier fs-1 position-absolute ms-6"></i>
+                    <input name="search" data-i18n="[placeholder]general.search" type="text" data-table-filter="search"
+                        class="form-control rounded-1 w-250px ps-15 me-5" placeholder="Search" />
+                </div>
 
-<div id="successMsg" class="card mb-4 border-left-success" style="display: none;">
-    <div id="successTxt" class="card-body"></div>
-</div>
+                <div class="d-flex justify-content-end my-2" data-table-toolbar="base">
+                    <button type="button" class="btn btn-light-primary rotate" data-kt-menu-trigger="click" data-kt-menu-placement="bottom" data-kt-menu-permanent="true">
+                        <span data-i18n="general.colvis">Column visibility</span>
+                        <i class="ki-duotone ki-down fs-3 rotate-180 ms-3 me-0"></i>
+                    </button>
+                    <div class="menu menu-sub menu-sub-dropdown menu-column menu-rounded menu-gray-800 menu-state-bg-light-primary fw-semibold w-auto min-w-200 mw-300px py-4" data-kt-menu="true">
+                        <div class="menu-item px-3 py-2 form-check form-check-sm form-check-custom form-check-solid">
+                            <input type="checkbox" class="form-check-input" value="" id="checkColStorage" />
+                            <label class="form-check-label" for="checkColStorage">
+                                <span data-i18n="storage.label" class="text-gray-800 fs-6">Storage</span>
+                            </label>
+                        </div>
+                        <div class="menu-item px-3 py-2 form-check form-check-sm form-check-custom form-check-solid">
+                            <input type="checkbox" class="form-check-input" value="" id="checkColQuota" />
+                            <label class="form-check-label" for="checkColQuota">
+                                <span data-i18n="fs.quota_usage.disk" class="text-gray-800 fs-6">Disk quota</span>
+                            </label>
+                        </div>
+                        <div class="menu-item px-3 py-2 form-check form-check-sm form-check-custom form-check-solid">
+                            <input type="checkbox" class="form-check-input" value="" id="checkColAssociations" />
+                            <label class="form-check-label" for="checkColAssociations">
+                                <span data-i18n="general.associations" class="text-gray-800 fs-6">Associations</span>
+                            </label>
+                        </div>
+                        <div class="menu-item px-3 py-2 form-check form-check-sm form-check-custom form-check-solid">
+                            <input type="checkbox" class="form-check-input" value="" id="checkColDesc" />
+                            <label class="form-check-label" for="checkColDesc">
+                                <span data-i18n="general.description" class="text-gray-800 fs-6">Description</span>
+                            </label>
+                        </div>
+                    </div>
+                    {{- if .LoggedUser.HasPermission "manage_folders"}}
+                    <a href="{{.FolderURL}}" class="btn btn-primary ms-5">
+                        <i class="ki-duotone ki-plus fs-2"></i>
+                        <span data-i18n="general.add">Add</span>
+                    </a>
+                    {{- end}}
+                </div>
+            </div>
+            </div>
 
-<div class="card shadow mb-4">
-    <div class="card-header py-3">
-        <h6 class="m-0 font-weight-bold text-primary">View and manage folders</h6>
-    </div>
-    <div class="card-body">
-        <div class="table-responsive">
-            <table class="table table-hover nowrap" id="dataTable" width="100%" cellspacing="0">
+            <table id="dataTable" class="table align-middle table-row-dashed fs-6 gy-5">
                 <thead>
-                    <tr>
-                        <th></th>
-                        <th>Name</th>
-                        <th>Storage</th>
-                        <th>Description</th>
-                        <th>Associated users</th>
-                        <th>Associated groups</th>
-                        <th>Quota</th>
+                    <tr class="text-start text-muted fw-bold fs-6 gs-0">
+                        <th data-i18n="general.name">Name</th>
+                        <th data-i18n="storage.label">Storage</th>
+                        <th data-i18n="fs.quota_usage.disk">Disk quota</th>
+                        <th data-i18n="general.associations">Associations</th>
+                        <th data-i18n="general.description">Description</th>
+                        <th class="min-w-100px"></th>
                     </tr>
                 </thead>
-                <tbody>
-                    {{range .Folders}}
-                    <tr>
-                        <td>{{.GetLastQuotaUpdateAsString}}</td>
-                        <td>{{.Name}}</td>
-                        <td>{{.GetStorageDescrition}}</td>
-                        <td>{{.Description}}</td>
-                        <td>{{.GetUsersAsString}}</td>
-                        <td>{{.GetGroupsAsString}}</td>
-                        <td>{{.GetQuotaSummary}}</td>
-                    </tr>
-                    {{end}}
-
-                </tbody>
+                <tbody id="table_body" class="text-gray-800 fw-semibold"></tbody>
             </table>
         </div>
     </div>
 </div>
-{{end}}
-
-{{define "dialog"}}
-<div class="modal fade" id="deleteModal" tabindex="-1" role="dialog" aria-labelledby="deleteModalLabel"
-    aria-hidden="true">
-    <div class="modal-dialog" role="document">
-        <div class="modal-content">
-            <div class="modal-header">
-                <h5 class="modal-title" id="deleteModalLabel">
-                    Confirmation required
-                </h5>
-                <button class="close" type="button" data-dismiss="modal" aria-label="Close">
-                    <span aria-hidden="true">&times;</span>
-                </button>
-            </div>
-            <div class="modal-body">Do you want to delete the selected virtual folder and any users mapping?</div>
-            <div class="modal-footer">
-                <button class="btn btn-secondary" type="button" data-dismiss="modal">
-                    Cancel
-                </button>
-                <a class="btn btn-warning" href="#" onclick="deleteAction()">
-                    Delete
-                </a>
-            </div>
-        </div>
-    </div>
-</div>
-{{end}}
-
-{{define "extra_js"}}
-<script src="{{.StaticURL}}/vendor/datatables/jquery.dataTables.min.js"></script>
-<script src="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.js"></script>
-<script src="{{.StaticURL}}/vendor/datatables/dataTables.buttons.min.js"></script>
-<script src="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.js"></script>
-<script src="{{.StaticURL}}/vendor/datatables/buttons.colVis.min.js"></script>
-<script src="{{.StaticURL}}/vendor/datatables/dataTables.fixedHeader.min.js"></script>
-<script src="{{.StaticURL}}/vendor/datatables/dataTables.responsive.min.js"></script>
-<script src="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.js"></script>
-<script src="{{.StaticURL}}/vendor/datatables/dataTables.select.min.js"></script>
-<script src="{{.StaticURL}}/vendor/datatables/ellipsis.js"></script>
-<script src="{{.StaticURL}}/vendor/datatables/dataTables.colReorder.min.js"></script>
-<script type="text/javascript">
-
-function deleteAction() {
-        let table = $('#dataTable').DataTable();
-        table.button('delete:name').enable(false);
-        let folderName = table.row({ selected: true }).data()[1];
-        let path = '{{.FolderURL}}' + "/" + fixedEncodeURIComponent(folderName);
-        $('#deleteModal').modal('hide');
-        $('#errorMsg').hide();
+{{- end}}
+{{- define "extra_js"}}
+<script {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}} src="{{.StaticURL}}/assets/plugins/custom/datatables/datatables.bundle.js"></script>
+<script type="text/javascript" {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}}>
+    function deleteAction(name) {
+        ModalAlert.fire({
+            text: $.t('general.delete_confirm_generic'),
+            icon: "warning",
+            confirmButtonText: $.t('general.delete_confirm_btn'),
+            cancelButtonText: $.t('general.cancel'),
+            customClass: {
+                confirmButton: "btn btn-danger",
+                cancelButton: 'btn btn-secondary'
+            }
+        }).then((result) => {
+            if (result.isConfirmed){
+                $('#loading_message').text("");
+                KTApp.showPageLoading();
+                let path = '{{.FolderURL}}' + "/" + encodeURIComponent(name);
 
-        $.ajax({
-            url: path,
-            type: 'DELETE',
-            dataType: 'json',
-            headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
-            timeout: 15000,
-            success: function (result) {
-                window.location.href = '{{.FoldersURL}}';
-            },
-            error: function ($xhr, textStatus, errorThrown) {
-                var txt = "Unable to delete the selected folder";
-                if ($xhr) {
-                    var json = $xhr.responseJSON;
-                    if (json) {
-                        if (json.message){
-                            txt += ": " + json.message;
-                        } else {
-                            txt += ": " + json.error;
+                axios.delete(path, {
+                    timeout: 15000,
+                    headers: {
+                        'X-CSRF-TOKEN': '{{.CSRFToken}}'
+                    },
+                    validateStatus: function (status) {
+                        return status == 200;
+                    }
+                }).then(function(response){
+                    location.reload();
+                }).catch(function(error){
+                    KTApp.hidePageLoading();
+                    let errorMessage;
+                    if (error && error.response) {
+                        switch (error.response.status) {
+                            case 403:
+                                errorMessage = "general.delete_error_403";
+                                break;
+                            case 404:
+                                errorMessage = "general.delete_error_404";
+                                break;
                         }
                     }
-                }
-                $('#errorTxt').text(txt);
-                $('#errorMsg').show();
+                    if (!errorMessage){
+                        errorMessage = "general.delete_error_generic";
+                    }
+                    ModalAlert.fire({
+                        text: $.t(errorMessage),
+                        icon: "warning",
+                        confirmButtonText: $.t('general.ok'),
+                        customClass: {
+                            confirmButton: "btn btn-primary"
+                        }
+                    });
+                });
             }
         });
     }
 
-    $(document).ready(function () {
-        $.fn.dataTable.ext.buttons.add = {
-            text: '<i class="fas fa-plus"></i>',
-            name: 'add',
-            titleAttr: "Add",
-            action: function (e, dt, node, config) {
-                window.location.href = '{{.FolderURL}}';
-            }
-        };
-
-        $.fn.dataTable.ext.buttons.edit = {
-            text: '<i class="fas fa-pen"></i>',
-            name: 'edit',
-            titleAttr: "Edit",
-            action: function (e, dt, node, config) {
-                var folderName = table.row({ selected: true }).data()[1];
-                var path = '{{.FolderURL}}' + "/" + fixedEncodeURIComponent(folderName);
-                window.location.href = path;
-            },
-            enabled: false
-        };
+    var datatable = function(){
+        var dt;
 
-        $.fn.dataTable.ext.buttons.template = {
-            text: '<i class="fas fa-clone"></i>',
-            name: 'template',
-            titleAttr: "Template",
-            action: function (e, dt, node, config) {
-                var selectedRows = table.rows({ selected: true }).count();
-                if (selectedRows == 1){
-                    var folderName = table.row({ selected: true }).data()[1];
-                    var path = '{{.FolderTemplateURL}}' + "?from=" + encodeURIComponent(folderName);
-                    window.location.href = path;
-                } else {
-                    window.location.href = '{{.FolderTemplateURL}}';
-                }
-            }
-        };
-
-        $.fn.dataTable.ext.buttons.delete = {
-            text: '<i class="fas fa-trash"></i>',
-            name: 'delete',
-            titleAttr: "Delete",
-            action: function (e, dt, node, config) {
-                $('#deleteModal').modal('show');
-            },
-            enabled: false
-        };
-
-        $.fn.dataTable.ext.buttons.quota_scan = {
-            text: '<i class="fas fa-redo-alt"></i>',
-            name: 'quota_scan',
-            titleAttr: 'Quota Scan',
-            action: function (e, dt, node, config) {
-                dt.button('quota_scan:name').enable(false);
-                let folderName = dt.row({ selected: true }).data()[1];
-                let path = '{{.FolderQuotaScanURL}}'+ "/" + fixedEncodeURIComponent(folderName);
-                $.ajax({
-                    url: path,
-                    type: 'POST',
-                    headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
-                    timeout: 15000,
-                    success: function (result) {
-                        //dt.button('quota_scan:name').enable(true);
-                        $('#successTxt').text("Quota scan started for the selected folder. Please reload the folders page to check when the scan ends");
-                        $('#successMsg').show();
-                        setTimeout(function () {
-                            $('#successMsg').hide();
-                        }, 15000);
-                    },
+        var initDatatable = function () {
+            $('#errorMsg').addClass("d-none");
+            dt = $('#dataTable').DataTable({
+                ajax: {
+                    url: "{{.FoldersURL}}/json",
+                    dataSrc: "",
                     error: function ($xhr, textStatus, errorThrown) {
-                        dt.button('quota_scan:name').enable(true);
-                        var txt = "Unable to update quota for the selected folder";
+                        $(".dataTables_processing").hide();
+                        let txt = "";
                         if ($xhr) {
-                            var json = $xhr.responseJSON;
+                            let json = $xhr.responseJSON;
                             if (json) {
-                                if (json.message) {
-                                    txt += ": " + json.message;
-                                } else if (json.error) {
-                                    txt += ": " + json.error;
+                                if (json.message){
+                                    txt = json.message;
                                 }
                             }
                         }
-                        $('#errorTxt').text(txt);
-                        $('#errorMsg').show();
+                        if (!txt){
+                            txt = "general.error500";
+                        }
+                        setI18NData($('#errorTxt'), txt);
+                        $('#errorMsg').removeClass("d-none");
                     }
-                });
-            },
-            enabled: false
-        };
-
-        let dateFn = $.fn.dataTable.render.datetime();
-        let table = $('#dataTable').DataTable({
-            "select": {
-                "style": "single",
-                "blurable": true
-            },
-            "colReorder": {
-                "enable": true,
-                "fixedColumnsLeft": 2
-            },
-            "stateSave": true,
-            "stateDuration": 0,
-            "buttons": [
-                {
-                    "text": "Column visibility",
-                    "extend": "colvis",
-                    "columns": ":not(.noVis)"
-                }
-            ],
-            "columnDefs": [
-                {
-                    "targets": [0],
-                    "visible": false,
-                    "className": "noVis"
                 },
-                {
-                    "targets": [1],
-                    "className": "noVis"
-                },
-                {
-                    "targets": [2],
-                    "render": $.fn.dataTable.render.ellipsis(50, true)
-                },
-                {
-                    "targets": [3],
-                    "visible": false
-                },
-                {
-                    "targets": [4],
-                    "render": $.fn.dataTable.render.ellipsis(40, true)
-                },
-                {
-                    "targets": [5],
-                    "visible": false,
-                    "render": $.fn.dataTable.render.ellipsis(40, true)
+                columns: [
+                    {
+                        data: "name",
+                        render: function(data, type, row) {
+                            if (type === 'display') {
+                                return escapeHTML(data);
+                            }
+                            return data;
+                        }
+                    },
+                    {
+                        data: "filesystem.provider",
+                        defaultContent: "",
+                        render: function(data, type, row) {
+                            if (type === 'display') {
+                                switch (data){
+                                    case 1:
+                                        return $.t('storage.s3');
+                                    case 2:
+                                        return $.t('storage.gcs');
+                                    case 3:
+                                        return $.t('storage.azblob');
+                                    case 4:
+                                        return $.t('storage.encrypted');
+                                    case 5:
+                                        return $.t('storage.sftp');
+                                    case 6:
+                                        return $.t('storage.http');
+                                    default:
+                                        return $.t('storage.local');
+                                }
+                            }
+                            return data;
+                        }
+                    },
+                    {
+                        data: "used_quota_size",
+                        visible: false,
+                        searchable: false,
+                        orderable: false,
+                        render: function(data, type, row) {
+                            if (type === 'display') {
+                                let val = "";
+                                if (row.used_quota_size) {
+                                    let usage = fileSizeIEC(row.used_quota_size);
+                                    val += $.t('fs.quota_usage.size', {val: usage})+". ";
+                                }
+                                if (row.used_quota_files){
+                                    val += $.t('fs.quota_usage.files', {val: row.used_quota_files});
+                                }
+                                return val
+                            }
+                            return data;
+                        }
+                    },
+                    {
+                        data: "users",
+                        defaultContent: "",
+                        visible: false,
+                        searchable: false,
+                        orderable: false,
+                        render: function(data, type, row) {
+                            if (type === 'display') {
+                                let users = 0;
+                                if (row.users){
+                                    users = row.users.length;
+                                }
+                                let groups = 0;
+                                if (row.groups){
+                                    groups = row.groups.length;
+                                }
+                                return $.t('virtual_folders.associations_summary', {users: users, groups: groups});
+                            }
+                            return "";
+                        }
+                    },
+                    {
+                        data: "description",
+                        visible: false,
+                        defaultContent: "",
+                        render: function(data, type, row) {
+                            if (type === 'display') {
+                                if (data){
+                                    return escapeHTML(data);
+                                }
+                                return ""
+                            }
+                            return data;
+                        }
+                    },
+                    {
+                        data: "id",
+                        searchable: false,
+                        orderable: false,
+                        className: 'text-end',
+                        render: function (data, type, row) {
+                            if (type === 'display') {
+                                let numActions = 0;
+                                let actions = `<button class="btn btn-light btn-active-light-primary btn-flex btn-center btn-sm rotate" data-kt-menu-trigger="click" data-kt-menu-placement="bottom-end">
+                                                    <span data-i18n="general.actions" class="fs-6">Actions</span>
+										            <i class="ki-duotone ki-down fs-5 ms-1 rotate-180"></i>
+                                                </button>
+                                                <div class="menu menu-sub menu-sub-dropdown menu-column menu-rounded menu-gray-700 menu-state-bg-light-primary fw-semibold fs-6 w-200px py-4" data-kt-menu="true">`;
+
+                                //{{- if .LoggedUser.HasPermission "manage_folders"}}
+                                numActions++;
+                                actions+=`<div class="menu-item px-3">
+										      <a data-i18n="general.edit" href="#" class="menu-link px-3" data-share-table-action="edit_row">Edit</a>
+										  </div>`
+                                numActions++;
+                                actions+=`<div class="menu-item px-3">
+                                             <a data-i18n="general.delete" href="#" class="menu-link text-danger px-3" data-share-table-action="delete_row">Delete</a>
+										  </div>`
+                                //{{- end}}
+                                if (numActions > 0){
+                                    actions+=`</div>`;
+                                    return actions;
+                                }
+                            }
+                            return "";
+                        }
+                    },
+                ],
+                deferRender: true,
+                stateSave: true,
+                stateDuration: 0,
+                colReorder: {
+                    enable: true,
+                    fixedColumnsLeft: 1
                 },
-                {
-                    "targets": [6],
-                    "visible": false,
-                    "render": function ( data, type, row, meta ) {
-                        if (type !== 'display') {
-			                return data;
-		                }
-                        if (row[0] !== ""){
-                            let formattedDate = dateFn(row[0], type);
-                            data = `${data}. Updated at: ${formattedDate}`;
+                stateLoadParams: function (settings, data) {
+                        if (data.search.search){
+                            const filterSearch = document.querySelector('[data-table-filter="search"]');
+                            filterSearch.value = data.search.search;
                         }
-                        let ellipsisFn = $.fn.dataTable.render.ellipsis(60, true);
-                        return ellipsisFn(data, type);
-                    }
+                    },
+                language: {
+                    info: $.t('datatable.info'),
+                    infoEmpty: $.t('datatable.info_empty'),
+                    infoFiltered: $.t('datatable.info_filtered'),
+                    loadingRecords: "",
+                    processing: $.t('datatable.processing'),
+                    zeroRecords: "",
+                    emptyTable: $.t('datatable.no_records')
+                },
+                order: [[0, 'asc']],
+                initComplete: function(settings, json) {
+                    $('#loader').addClass("d-none");
+                    $('#card_content').removeClass("d-none");
+                    let api = $.fn.dataTable.Api(settings);
+                    api.columns.adjust().draw("page");
+                    drawAction();
                 }
-            ],
-            "scrollX": false,
-            "scrollY": false,
-            "responsive": true,
-            "language": {
-                "emptyTable": "No folder defined"
-            },
-            "order": [[1, 'asc']]
-        });
-
-        new $.fn.dataTable.FixedHeader( table );
+            });
 
-        {{if .LoggedAdmin.HasPermission "quota_scans"}}
-        table.button().add(0,'quota_scan');
-        {{end}}
+            dt.on('draw', drawAction);
+            dt.on('column-reorder', function(e, settings, details){
+                drawAction();
+            });
+        }
 
-        {{if .LoggedAdmin.HasPermission "del_users"}}
-        table.button().add(0,'delete');
-        {{end}}
+        function drawAction() {
+            KTMenu.createInstances();
+            handleRowActions();
+            $('#table_body').localize();
+        }
 
-        {{if .LoggedAdmin.HasPermission "add_users"}}
-        table.button().add(0,'template');
-        {{end}}
+        function handleColVisibilityCheckbox(el, index) {
+            el.off("change");
+            el.prop('checked', dt.column(index).visible());
+            el.on("change", function(e){
+                dt.column(index).visible($(this).is(':checked'));
+                dt.draw('page');
+            });
+        }
 
-        {{if .LoggedAdmin.HasPermission "edit_users"}}
-        table.button().add(0,'edit');
-        {{end}}
+        var handleDatatableActions = function () {
+            const filterSearch = $(document.querySelector('[data-table-filter="search"]'));
+            filterSearch.off("keyup");
+            filterSearch.on('keyup', function (e) {
+                dt.rows().deselect();
+                dt.search(e.target.value, true, false).draw();
+            });
+            handleColVisibilityCheckbox($('#checkColStorage'), 1);
+            handleColVisibilityCheckbox($('#checkColQuota'), 2);
+            handleColVisibilityCheckbox($('#checkColAssociations'), 3);
+            handleColVisibilityCheckbox($('#checkColDesc'), 4);
+        }
 
-        {{if .LoggedAdmin.HasPermission "add_users"}}
-        table.button().add(0,'add');
-        {{end}}
+        function handleRowActions() {
+            const editButtons = document.querySelectorAll('[data-share-table-action="edit_row"]');
+            editButtons.forEach(d => {
+                let el = $(d);
+                el.off("click");
+                el.on("click", function(e){
+                    e.preventDefault();
+                    let rowData = dt.row(e.target.closest('tr')).data();
+                    window.location.replace('{{.FolderURL}}' + "/" + encodeURIComponent(rowData['name']));
+                });
+            });
 
-        table.buttons().container().appendTo('.col-md-6:eq(0)', table.table().container());
+            const deleteButtons = document.querySelectorAll('[data-share-table-action="delete_row"]');
+            deleteButtons.forEach(d => {
+                let el = $(d);
+                el.off("click");
+                el.on("click", function(e){
+                    e.preventDefault();
+                    const parent = e.target.closest('tr');
+                    deleteAction(dt.row(parent).data()['name']);
+                });
+            });
+        }
 
-        table.on('select deselect', function () {
-            var selectedRows = table.rows({ selected: true }).count();
-            {{if .LoggedAdmin.HasPermission "del_users"}}
-            table.button('delete:name').enable(selectedRows == 1);
-            {{end}}
-            {{if .LoggedAdmin.HasPermission "edit_users"}}
-            table.button('edit:name').enable(selectedRows == 1);
-            {{end}}
-            {{if .LoggedAdmin.HasPermission "quota_scans"}}
-            table.button('quota_scan:name').enable(selectedRows == 1);
-            {{end}}
-        });
+        return {
+            init: function () {
+                initDatatable();
+                handleDatatableActions();
+            }
+        }
+    }();
 
+    $(document).on("i18nshow", function(){
+        datatable.init();
     });
-
 </script>
-{{end}}
+{{- end}}

+ 8 - 1
templates/webadmin/groups.html

@@ -126,7 +126,14 @@ explicit grant from the SFTPGo Team ([email protected]).
                     if (!errorMessage){
                         errorMessage = "general.delete_error_generic";
                     }
-                    showToast(2, errorMessage);
+                    ModalAlert.fire({
+                        text: $.t(errorMessage),
+                        icon: "warning",
+                        confirmButtonText: $.t('general.ok'),
+                        customClass: {
+                            confirmButton: "btn btn-primary"
+                        }
+                    });
                 });
             }
         });

+ 17 - 3
templates/webadmin/users.html

@@ -175,7 +175,14 @@ explicit grant from the SFTPGo Team ([email protected]).
                     if (!errorMessage){
                         errorMessage = "general.delete_error_generic";
                     }
-                    showToast(2, errorMessage);
+                    ModalAlert.fire({
+                        text: $.t(errorMessage),
+                        icon: "warning",
+                        confirmButtonText: $.t('general.ok'),
+                        customClass: {
+                            confirmButton: "btn btn-primary"
+                        }
+                    });
                 });
             }
         });
@@ -209,7 +216,14 @@ explicit grant from the SFTPGo Team ([email protected]).
                 if (!errorMessage) {
                     errorMessage = "general.quota_scan_error";
                 }
-                showToast(2, errorMessage);
+                ModalAlert.fire({
+                    text: $.t(errorMessage),
+                    icon: "warning",
+                    confirmButtonText: $.t('general.ok'),
+                    customClass: {
+                        confirmButton: "btn btn-primary"
+                    }
+                });
             });
         }
 
@@ -364,7 +378,7 @@ explicit grant from the SFTPGo Team ([email protected]).
                                     if (!used){
                                         used = 0;
                                     }
-                                    let usage = fileSizeIEC(used)+"/"+fileSizeIEC(row.quota_size)
+                                    let usage = fileSizeIEC(used)+"/"+fileSizeIEC(row.quota_size);
                                     val += $.t('fs.quota_usage.size', {val: usage})+". ";
                                 } else if (row.used_quota_size && row.used_quota_size > 0) {
                                     val += $.t('fs.quota_usage.size', {val: fileSizeIEC(row.used_quota_size)})+". ";

+ 120 - 15
templates/webclient/files.html

@@ -1224,7 +1224,14 @@ explicit grant from the SFTPGo Team ([email protected]).
         }
         items = checkMoveCopyItems(items)
         if (items.length == 0){
-            showToast(2, "fs.invalid_name");
+            ModalAlert.fire({
+                text: $.t('fs.invalid_name'),
+                icon: "warning",
+                confirmButtonText: $.t('general.ok'),
+                customClass: {
+                    confirmButton: "btn btn-primary"
+                }
+            });
             return;
         }
         keepAlive();
@@ -1296,7 +1303,14 @@ explicit grant from the SFTPGo Team ([email protected]).
                 if (!errorMessage){
                     errorMessage = "fs.copy.err_generic";
                 }
-                showToast(2, errorMessage);
+                ModalAlert.fire({
+                    text: $.t(errorMessage),
+                    icon: "warning",
+                    confirmButtonText: $.t('general.ok'),
+                    customClass: {
+                        confirmButton: "btn btn-primary"
+                    }
+                });
                 copyItem();
             });
         }
@@ -1315,10 +1329,24 @@ explicit grant from the SFTPGo Team ([email protected]).
         }).then((result)=>{
             if (result.error) {
                 hasError = true;
-                showToast(2, "fs.copy.err_generic");
+                ModalAlert.fire({
+                    text: $.t("fs.copy.err_generic"),
+                    icon: "warning",
+                    confirmButtonText: $.t('general.ok'),
+                    customClass: {
+                        confirmButton: "btn btn-primary"
+                    }
+                });
             } else if (result.data.length > 0){
                 hasError = true;
-                showToast(2, "fs.copy.err_exists");
+                ModalAlert.fire({
+                    text: $.t("fs.copy.err_exists"),
+                    icon: "warning",
+                    confirmButtonText: $.t('general.ok'),
+                    customClass: {
+                        confirmButton: "btn btn-primary"
+                    }
+                });
             }
             copyItem();
         });
@@ -1332,7 +1360,14 @@ explicit grant from the SFTPGo Team ([email protected]).
         }
         items = checkMoveCopyItems(items)
         if (items.length == 0){
-            showToast(2, "fs.invalid_name");
+            ModalAlert.fire({
+                text: $.t("fs.invalid_name"),
+                icon: "warning",
+                confirmButtonText: $.t('general.ok'),
+                customClass: {
+                    confirmButton: "btn btn-primary"
+                }
+            });
             return;
         }
         keepAlive();
@@ -1404,7 +1439,14 @@ explicit grant from the SFTPGo Team ([email protected]).
                 if (!errorMessage){
                     errorMessage = "fs.move.err_generic";
                 }
-                showToast(2, errorMessage);
+                ModalAlert.fire({
+                    text: $.t(errorMessage),
+                    icon: "warning",
+                    confirmButtonText: $.t('general.ok'),
+                    customClass: {
+                        confirmButton: "btn btn-primary"
+                    }
+                });
                 moveItem();
             });
         }
@@ -1423,10 +1465,24 @@ explicit grant from the SFTPGo Team ([email protected]).
         }).then((result)=>{
             if (result.error) {
                 hasError = true;
-                showToast(2, "fs.move.err_generic");
+                ModalAlert.fire({
+                text: $.t("fs.move.err_generic"),
+                icon: "warning",
+                confirmButtonText: $.t('general.ok'),
+                customClass: {
+                    confirmButton: "btn btn-primary"
+                }
+            });
             } else if (result.data.length > 0){
                 hasError = true;
-                showToast(2, "fs.move.err_exists");
+                ModalAlert.fire({
+                text: $.t("fs.move.err_exists"),
+                icon: "warning",
+                confirmButtonText: $.t('general.ok'),
+                customClass: {
+                    confirmButton: "btn btn-primary"
+                }
+            });
             }
             moveItem();
         });
@@ -1493,7 +1549,14 @@ explicit grant from the SFTPGo Team ([email protected]).
                     if (!errorMessage){
                         errorMessage = "fs.delete.err_generic";
                     }
-                    showToast(2, errorMessage, {name: itemName});
+                    ModalAlert.fire({
+                        text: $.t(errorMessage, {name: itemName}),
+                        icon: "warning",
+                        confirmButtonText: $.t('general.ok'),
+                        customClass: {
+                            confirmButton: "btn btn-primary"
+                        }
+                    });
                 });
             }
         });
@@ -1523,15 +1586,36 @@ explicit grant from the SFTPGo Team ([email protected]).
         let oldName = getNameFromMeta(meta);
         let newName = $('#rename_new_name').val();
         if (!newName){
-            showToast(2, "general.name_required");
+            ModalAlert.fire({
+                text: $.t('general.name_required'),
+                icon: "warning",
+                confirmButtonText: $.t('general.ok'),
+                customClass: {
+                    confirmButton: "btn btn-primary"
+                }
+            });
             return;
         }
         if (newName == oldName){
-            showToast(2, "general.name_different");
+            ModalAlert.fire({
+                text: $.t('general.name_different'),
+                icon: "warning",
+                confirmButtonText: $.t('general.ok'),
+                customClass: {
+                    confirmButton: "btn btn-primary"
+                }
+            });
             return;
         }
         if (newName.includes("/")){
-            showToast(2, "fs.invalid_name");
+            ModalAlert.fire({
+                text: $.t('fs.invalid_name'),
+                icon: "warning",
+                confirmButtonText: $.t('general.ok'),
+                customClass: {
+                    confirmButton: "btn btn-primary"
+                }
+            });
             return;
         }
 
@@ -1567,7 +1651,14 @@ explicit grant from the SFTPGo Team ([email protected]).
                 if (!errorMessage) {
                     errorMessage = "fs.rename.err_generic";
                 }
-                showToast(2, errorMessage, { name: oldName });
+                ModalAlert.fire({
+                        text: $.t(errorMessage, {name: oldName}),
+                        icon: "warning",
+                        confirmButtonText: $.t('general.ok'),
+                        customClass: {
+                            confirmButton: "btn btn-primary"
+                        }
+                    });
             });
         }
 
@@ -1578,12 +1669,26 @@ explicit grant from the SFTPGo Team ([email protected]).
         }).then((result)=>{
             if (result.error) {
                 KTApp.hidePageLoading();
-                showToast(2, "fs.rename.err_generic", { name: oldName });
+                ModalAlert.fire({
+                    text: $.t('fs.rename.err_generic', { name: oldName }),
+                    icon: "warning",
+                    confirmButtonText: $.t('general.ok'),
+                    customClass: {
+                        confirmButton: "btn btn-primary"
+                    }
+                });
                 return;
             }
             if (result.data.length > 0){
                 KTApp.hidePageLoading();
-                showToast(2, "fs.rename.err_exists", { name: oldName });
+                ModalAlert.fire({
+                    text: $.t('fs.rename.err_exists', { name: oldName }),
+                    icon: "warning",
+                    confirmButtonText: $.t('general.ok'),
+                    customClass: {
+                        confirmButton: "btn btn-primary"
+                    }
+                });
                 return;
             }
             executeRename();

+ 8 - 1
templates/webclient/shares.html

@@ -205,7 +205,14 @@ explicit grant from the SFTPGo Team ([email protected]).
                     if (!errorMessage){
                         errorMessage = "general.delete_error_generic";
                     }
-                    showToast(2, errorMessage);
+                    ModalAlert.fire({
+                        text: $.t(errorMessage),
+                        icon: "warning",
+                        confirmButtonText: $.t('general.ok'),
+                        customClass: {
+                            confirmButton: "btn btn-primary"
+                        }
+                    });
                 });
             }
         });