Browse Source

[ui-storagebrowser] adds change owner and group action (#3973)

Ram Prasad Agarwal 10 months ago
parent
commit
e5d26edf46

+ 1 - 1
apps/filebrowser/src/filebrowser/api.py

@@ -734,7 +734,7 @@ def chown(request):
   path = request.POST.get('path')
   user = request.POST.get("user")
   group = request.POST.get("group")
-  recursive = request.POST.get('recursive', False)
+  recursive = coerce_bool(request.POST.get('recursive', False))
 
   # TODO: Check if we need to explicitly handle encoding anywhere
   request.fs.chown(path, user, group, recursive=recursive)

+ 59 - 0
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ChangeOwnerAndGroupModal/ChangeOwnerAndGroupModal.scss

@@ -0,0 +1,59 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+@use 'variables' as vars;
+
+.antd.cuix {
+  .hue-change-owner-group {
+    display: flex;
+    flex-direction: column;
+    flex: 1;
+    gap: 8px;
+
+    &__header-note {
+      padding: 8px;
+      background-color: vars.$fluidx-gray-200;
+      margin-bottom: 8px;
+    }
+
+    &__form {
+      display: flex;
+      flex-direction: column;
+      flex: 1;
+      gap: 16px;
+    }
+
+    &__entity {
+      display: flex;
+      flex-direction: column;
+    }
+
+    &__dropdown {
+      display: grid;
+      grid-template-columns: 1fr 1fr;
+      grid-gap: 8px;
+    }
+
+    &__checkbox {
+      display: flex;
+      gap: 8px;
+    }
+
+    &__label {
+      color: vars.$fluidx-gray-700;
+    }
+  }
+}

+ 297 - 0
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ChangeOwnerAndGroupModal/ChangeOwnerAndGroupModal.test.tsx

@@ -0,0 +1,297 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React from 'react';
+import { render, fireEvent, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import userEvent from '@testing-library/user-event';
+import ChangeOwnerAndGroupModal from './ChangeOwnerAndGroupModal';
+import { StorageDirectoryTableData } from '../../../../../reactComponents/FileChooser/types';
+
+const mockFiles: StorageDirectoryTableData[] = [
+  {
+    name: 'file1.txt',
+    size: '0 Byte',
+    type: 'file',
+    permission: 'rwxrwxrwx',
+    mtime: '2021-01-01 00:00:00',
+    path: 'test/path/file1.txt',
+    user: 'user1',
+    group: 'group1',
+    replication: 1
+  }
+];
+
+const mockSave = jest.fn();
+jest.mock('../../../../../utils/hooks/useSaveData/useSaveData', () => ({
+  __esModule: true,
+  default: jest.fn(() => ({
+    save: mockSave,
+    loading: false
+  }))
+}));
+
+const mockOnSuccess = jest.fn();
+const mockOnError = jest.fn();
+const mockOnClose = jest.fn();
+
+const users = ['user1', 'user2', 'user3'];
+const groups = ['group1', 'group2', 'group3'];
+
+describe('ChangeOwnerAndGroupModal Component', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it('should render correctly and show the modal', () => {
+    const { getByText } = render(
+      <ChangeOwnerAndGroupModal
+        isOpen={true}
+        superUser="hadoop-superuser"
+        superGroup="hdfs-supergroup"
+        users={users}
+        groups={groups}
+        files={mockFiles}
+        setLoading={jest.fn()}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+      />
+    );
+
+    expect(getByText('Change Onwer / Group')).toBeInTheDocument();
+    expect(getByText('Submit')).toBeInTheDocument();
+    expect(getByText('Cancel')).toBeInTheDocument();
+    expect(getByText('User')).toBeInTheDocument();
+    expect(getByText('Group')).toBeInTheDocument();
+    expect(getByText('Recursive')).toBeInTheDocument();
+    expect(
+      getByText(
+        'Note: Only the Hadoop superuser, "{{superuser}}" or the HDFS supergroup, "{{supergroup}}" on this file system, may change the owner of a file.'
+      )
+    ).toBeInTheDocument();
+  });
+
+  it('should show input fields for custom user when "Others" is selected', async () => {
+    const { getAllByRole, getByText, getByPlaceholderText } = render(
+      <ChangeOwnerAndGroupModal
+        isOpen={true}
+        superUser="hadoop-superuser"
+        superGroup="hdfs-supergroup"
+        users={users}
+        groups={groups}
+        files={mockFiles}
+        setLoading={jest.fn()}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+      />
+    );
+
+    const [userSelect] = getAllByRole('combobox');
+
+    await userEvent.click(userSelect);
+    fireEvent.click(getByText('others'));
+    fireEvent.change(userSelect, { target: { value: 'others' } });
+
+    const userInput = getByPlaceholderText('Enter user');
+    expect(userInput).toBeInTheDocument();
+
+    fireEvent.change(userInput, { target: { value: 'customUser' } });
+    expect(userInput).toHaveValue('customUser');
+  });
+
+  it('should show input fields for custom group when "Others" is selected', async () => {
+    const { getAllByRole, getByText, getByPlaceholderText } = render(
+      <ChangeOwnerAndGroupModal
+        isOpen={true}
+        superUser="hadoop-superuser"
+        superGroup="hdfs-supergroup"
+        users={users}
+        groups={groups}
+        files={mockFiles}
+        setLoading={jest.fn()}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+      />
+    );
+
+    const groupSelect = getAllByRole('combobox')[1];
+
+    await userEvent.click(groupSelect);
+    fireEvent.click(getByText('others'));
+    fireEvent.change(groupSelect, { target: { value: 'others' } });
+
+    const groupInput = getByPlaceholderText('Enter group');
+    expect(groupInput).toBeInTheDocument();
+
+    fireEvent.change(groupInput, { target: { value: 'customGroup' } });
+    expect(groupInput).toHaveValue('customGroup');
+  });
+
+  it('should toggle the recursive checkbox', () => {
+    const { getByRole } = render(
+      <ChangeOwnerAndGroupModal
+        isOpen={true}
+        superUser="hadoop-superuser"
+        superGroup="hdfs-supergroup"
+        users={users}
+        groups={groups}
+        files={mockFiles}
+        setLoading={jest.fn()}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+      />
+    );
+
+    const recursiveCheckbox = getByRole('checkbox');
+    expect(recursiveCheckbox).not.toBeChecked();
+    fireEvent.click(recursiveCheckbox);
+    expect(recursiveCheckbox).toBeChecked();
+    fireEvent.click(recursiveCheckbox);
+    expect(recursiveCheckbox).not.toBeChecked();
+  });
+
+  it('should call handleChangeOwner when the form is submitted', async () => {
+    const { getByText } = render(
+      <ChangeOwnerAndGroupModal
+        isOpen={true}
+        superUser="hadoop-superuser"
+        superGroup="hdfs-supergroup"
+        users={users}
+        groups={groups}
+        files={mockFiles}
+        setLoading={jest.fn()}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+      />
+    );
+
+    fireEvent.click(getByText('Submit'));
+
+    await waitFor(() => {
+      expect(mockSave).toHaveBeenCalledTimes(1);
+      expect(mockSave).toHaveBeenCalledWith(expect.any(FormData));
+    });
+  });
+
+  it('should call onSuccess when the request is successful', async () => {
+    mockSave.mockImplementationOnce(() => {
+      mockOnSuccess();
+    });
+
+    const { getByText } = render(
+      <ChangeOwnerAndGroupModal
+        isOpen={true}
+        superUser="hadoop-superuser"
+        superGroup="hdfs-supergroup"
+        users={users}
+        groups={groups}
+        files={mockFiles}
+        setLoading={jest.fn()}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+      />
+    );
+
+    fireEvent.click(getByText('Submit'));
+
+    await waitFor(() => {
+      expect(mockOnSuccess).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  it('should call onError when the request fails', async () => {
+    mockSave.mockImplementationOnce(() => {
+      mockOnError(new Error());
+    });
+
+    const { getByText } = render(
+      <ChangeOwnerAndGroupModal
+        isOpen={true}
+        superUser="hadoop-superuser"
+        superGroup="hdfs-supergroup"
+        users={users}
+        groups={groups}
+        files={mockFiles}
+        setLoading={jest.fn()}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+      />
+    );
+
+    fireEvent.click(getByText('Submit'));
+
+    await waitFor(() => {
+      expect(mockOnError).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  it('should call onClose when the modal is closed', () => {
+    const { getByText } = render(
+      <ChangeOwnerAndGroupModal
+        isOpen={true}
+        superUser="hadoop-superuser"
+        superGroup="hdfs-supergroup"
+        users={users}
+        groups={groups}
+        files={mockFiles}
+        setLoading={jest.fn()}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+      />
+    );
+
+    fireEvent.click(getByText('Cancel'));
+    expect(mockOnClose).toHaveBeenCalledTimes(1);
+  });
+
+  it('should disable submit button when other option is selected and input not provided', async () => {
+    const { getAllByRole, getAllByText, getByPlaceholderText, getByRole } = render(
+      <ChangeOwnerAndGroupModal
+        isOpen={true}
+        superUser="hadoop-superuser"
+        superGroup="hdfs-supergroup"
+        users={users}
+        groups={groups}
+        files={mockFiles}
+        setLoading={jest.fn()}
+        onSuccess={mockOnSuccess}
+        onError={mockOnError}
+        onClose={mockOnClose}
+      />
+    );
+
+    const [userSelect] = getAllByRole('combobox');
+
+    await userEvent.click(userSelect);
+    fireEvent.click(getAllByText('others')[0]);
+    fireEvent.change(userSelect, { target: { value: 'others' } });
+
+    const submitButton = getByRole('button', { name: /submit/i });
+    expect(submitButton).toBeDisabled();
+
+    const userInput = getByPlaceholderText('Enter user');
+    fireEvent.change(userInput, { target: { value: 'customUser' } });
+    expect(submitButton).toBeEnabled();
+  });
+});

+ 197 - 0
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/ChangeOwnerAndGroupModal/ChangeOwnerAndGroupModal.tsx

@@ -0,0 +1,197 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React, { useEffect, useMemo, useState } from 'react';
+import Modal from 'cuix/dist/components/Modal';
+import { i18nReact } from '../../../../../utils/i18nReact';
+import useSaveData from '../../../../../utils/hooks/useSaveData/useSaveData';
+import { Checkbox, Input, Select } from 'antd';
+import {
+  ListDirectory,
+  StorageDirectoryTableData
+} from '../../../../../reactComponents/FileChooser/types';
+import { BULK_CHANGE_OWNER_API_URL } from '../../../../../reactComponents/FileChooser/api';
+import './ChangeOwnerAndGroupModal.scss';
+
+interface ChangeOwnerAndGroupModalProps {
+  superUser?: ListDirectory['superuser'];
+  superGroup?: ListDirectory['supergroup'];
+  users?: ListDirectory['users'];
+  groups?: ListDirectory['groups'];
+  isOpen?: boolean;
+  files: StorageDirectoryTableData[];
+  setLoading: (value: boolean) => void;
+  onSuccess: () => void;
+  onError: (error: Error) => void;
+  onClose: () => void;
+}
+
+const OTHERS_KEY = 'others';
+const getDropdownOptions = (entity: ListDirectory['users'] | ListDirectory['groups']) => {
+  return [...entity, OTHERS_KEY].map(user => ({
+    value: user,
+    label: user
+  }));
+};
+
+const ChangeOwnerAndGroupModal = ({
+  superUser,
+  superGroup,
+  users = [],
+  groups = [],
+  isOpen = true,
+  files,
+  setLoading,
+  onSuccess,
+  onError,
+  onClose
+}: ChangeOwnerAndGroupModalProps): JSX.Element => {
+  const { t } = i18nReact.useTranslation();
+
+  const [selectedUser, setSelectedUser] = useState<string>(files[0].user);
+  const [selectedGroup, setSelectedGroup] = useState<string>(files[0].group);
+  const [userOther, setUserOther] = useState<string>();
+  const [groupOther, setGroupOther] = useState<string>();
+  const [isRecursive, setIsRecursive] = useState<boolean>(false);
+
+  const { save, loading } = useSaveData(BULK_CHANGE_OWNER_API_URL, {
+    postOptions: {
+      qsEncodeData: false
+    },
+    skip: !files.length,
+    onSuccess,
+    onError
+  });
+
+  const handleChangeOwner = () => {
+    setLoading(true);
+
+    const formData = new FormData();
+    if (selectedUser === OTHERS_KEY && userOther) {
+      formData.append('user', userOther);
+    } else {
+      formData.append('user', selectedUser);
+    }
+    if (selectedGroup === OTHERS_KEY && groupOther) {
+      formData.append('group', groupOther);
+    } else {
+      formData.append('group', selectedGroup);
+    }
+    if (isRecursive) {
+      formData.append('recursive', String(isRecursive));
+    }
+    files.forEach(file => {
+      formData.append('path', file.path);
+    });
+
+    save(formData);
+  };
+
+  const usersOptions = getDropdownOptions(users);
+  const groupOptions = getDropdownOptions(groups);
+
+  useEffect(() => {
+    const isOtherUserSelected = !users.includes(files[0].user);
+    if (isOtherUserSelected) {
+      setSelectedUser(OTHERS_KEY);
+      setUserOther(files[0].user);
+    }
+
+    const isOtherGroupSelected = !groups.includes(files[0].group);
+    if (isOtherGroupSelected) {
+      setSelectedGroup(OTHERS_KEY);
+      setGroupOther(files[0].group);
+    }
+  }, []);
+
+  const isSubmitEnabled = useMemo(() => {
+    return Boolean(
+      selectedUser &&
+        selectedGroup &&
+        !(selectedUser === OTHERS_KEY && !userOther) &&
+        !(selectedGroup === OTHERS_KEY && !groupOther)
+    );
+  }, [selectedUser, selectedGroup, userOther, groupOther]);
+
+  return (
+    <Modal
+      cancelText={t('Cancel')}
+      className="cuix antd"
+      okText={t('Submit')}
+      onCancel={onClose}
+      onOk={handleChangeOwner}
+      open={isOpen}
+      title={t('Change Onwer / Group')}
+      okButtonProps={{ disabled: loading || !isSubmitEnabled }}
+      cancelButtonProps={{ disabled: loading }}
+    >
+      <div className="hue-change-owner-group">
+        <span className="hue-change-owner-group__header-note">
+          {t(
+            'Note: Only the Hadoop superuser, "{{superuser}}" or the HDFS supergroup, "{{supergroup}}" on this file system, may change the owner of a file.',
+            {
+              superuser: superUser,
+              supergroup: superGroup
+            }
+          )}
+        </span>
+
+        <div className="hue-change-owner-group__form">
+          <div className="hue-change-owner-group__entity">
+            <div className="hue-change-owner-group__label">{t('User')}</div>
+            <div className="hue-change-owner-group__dropdown">
+              <Select options={usersOptions} onChange={setSelectedUser} value={selectedUser} />
+              {selectedUser === OTHERS_KEY && (
+                <Input
+                  placeholder={t('Enter user')}
+                  value={userOther}
+                  onChange={e => setUserOther(e.target.value)}
+                  required
+                />
+              )}
+            </div>
+          </div>
+
+          <div className="hue-change-owner-group__entity">
+            <div className="hue-change-owner-group__label">{t('Group')}</div>
+            <div className="hue-change-owner-group__dropdown">
+              <Select options={groupOptions} onChange={setSelectedGroup} value={selectedGroup} />
+              {selectedGroup === OTHERS_KEY && (
+                <Input
+                  placeholder={t('Enter group')}
+                  value={groupOther}
+                  onChange={e => setGroupOther(e.target.value)}
+                  required
+                />
+              )}
+            </div>
+          </div>
+
+          <div className="hue-change-owner-group__checkbox">
+            <span className="hue-change-owner-group__label">{t('Recursive')}</span>
+            <Checkbox
+              checked={isRecursive}
+              onChange={() => setIsRecursive(prev => !prev)}
+              name="recursive"
+            />
+          </div>
+        </div>
+      </div>
+    </Modal>
+  );
+};
+
+export default ChangeOwnerAndGroupModal;

+ 30 - 2
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/StorageBrowserActions.tsx

@@ -29,12 +29,14 @@ import DeleteIcon from '@cloudera/cuix-core/icons/react/DeleteIcon';
 import CollapseIcon from '@cloudera/cuix-core/icons/react/CollapseViewIcon';
 import ExpandIcon from '@cloudera/cuix-core/icons/react/ExpandViewIcon';
 import DownloadIcon from '@cloudera/cuix-core/icons/react/DownloadIcon';
+import GroupsIcon from '@cloudera/cuix-core/icons/react/GroupsIcon';
 
 import { i18nReact } from '../../../../utils/i18nReact';
 import huePubSub from '../../../../utils/huePubSub';
 import './StorageBrowserActions.scss';
 import {
   FileStats,
+  ListDirectory,
   StorageDirectoryTableData
 } from '../../../../reactComponents/FileChooser/types';
 import { ActionType, getEnabledActions } from './StorageBrowserActions.util';
@@ -46,8 +48,15 @@ import DeletionModal from './DeletionModal/DeletionModal';
 import CompressionModal from './CompressionModal/CompressionModal';
 import ExtractionModal from './ExtractionModal/ExtractionModal';
 import { DOWNLOAD_API_URL } from '../../../../reactComponents/FileChooser/api';
+import ChangeOwnerAndGroupModal from './ChangeOwnerAndGroupModal/ChangeOwnerAndGroupModal';
 
 interface StorageBrowserRowActionsProps {
+  // TODO: move relevant keys to hue_config
+  superUser?: ListDirectory['superuser'];
+  superGroup?: ListDirectory['supergroup'];
+  users?: ListDirectory['users'];
+  groups?: ListDirectory['groups'];
+  isFsSuperUser?: ListDirectory['is_fs_superuser'];
   isTrashEnabled?: boolean;
   currentPath: FileStats['path'];
   selectedFiles: StorageDirectoryTableData[];
@@ -64,10 +73,16 @@ const iconsMap: Record<ActionType, JSX.Element> = {
   [ActionType.Summary]: <SummaryIcon />,
   [ActionType.Compress]: <CollapseIcon />,
   [ActionType.Extract]: <ExpandIcon />,
-  [ActionType.Download]: <DownloadIcon />
+  [ActionType.Download]: <DownloadIcon />,
+  [ActionType.ChangeOwnerAndGroup]: <GroupsIcon />
 };
 
 const StorageBrowserActions = ({
+  superUser,
+  superGroup,
+  users,
+  groups,
+  isFsSuperUser,
   isTrashEnabled,
   currentPath,
   selectedFiles,
@@ -106,7 +121,7 @@ const StorageBrowserActions = ({
   };
 
   const actionItems: MenuItemType[] = useMemo(() => {
-    const enabledActions = getEnabledActions(selectedFiles);
+    const enabledActions = getEnabledActions(selectedFiles, isFsSuperUser);
     return enabledActions.map(action => ({
       key: String(action.type),
       label: t(action.label),
@@ -191,6 +206,19 @@ const StorageBrowserActions = ({
           setLoading={setLoadingFiles}
         />
       )}
+      {selectedAction === ActionType.ChangeOwnerAndGroup && (
+        <ChangeOwnerAndGroupModal
+          files={selectedFiles}
+          superUser={superUser}
+          superGroup={superGroup}
+          users={users}
+          groups={groups}
+          onSuccess={onApiSuccess}
+          onError={onApiError}
+          onClose={closeModal}
+          setLoading={setLoadingFiles}
+        />
+      )}
     </>
   );
 };

+ 12 - 2
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageBrowserActions/StorageBrowserActions.util.ts

@@ -44,7 +44,8 @@ export enum ActionType {
   Delete = 'delete',
   Compress = 'compress',
   Extract = 'extract',
-  Download = 'download'
+  Download = 'download',
+  ChangeOwnerAndGroup = 'changeOwnerAndGroup'
 }
 
 const isValidFileOrFolder = (filePath: string): boolean => {
@@ -83,6 +84,8 @@ const isActionEnabled = (file: StorageDirectoryTableData, action: ActionType): b
       return !!config?.enable_extract_uploaded_archive && isHDFS(file.path);
     case ActionType.Download:
       return !!config?.enable_file_download_button && file.type === BrowserViewType.file;
+    case ActionType.ChangeOwnerAndGroup:
+      return isValidFileOrFolder(file.path);
     default:
       return false;
   }
@@ -103,7 +106,8 @@ const isMultipleFileActionEnabled = (
 };
 
 export const getEnabledActions = (
-  files: StorageDirectoryTableData[]
+  files: StorageDirectoryTableData[],
+  isFsSuperUser?: boolean
 ): {
   enabled: boolean;
   type: ActionType;
@@ -161,6 +165,12 @@ export const getEnabledActions = (
       enabled: isSingleFileActionEnabled(files, ActionType.Download),
       type: ActionType.Download,
       label: 'Download'
+    },
+    {
+      enabled:
+        !!isFsSuperUser && isMultipleFileActionEnabled(files, ActionType.ChangeOwnerAndGroup),
+      type: ActionType.ChangeOwnerAndGroup,
+      label: 'Change Owner / Group'
     }
   ].filter(e => e.enabled);
 

+ 5 - 0
desktop/core/src/desktop/js/apps/storageBrowser/StorageDirectoryPage/StorageDirectoryPage.tsx

@@ -285,6 +285,11 @@ const StorageDirectoryPage = ({
           <StorageBrowserActions
             currentPath={fileStats.path}
             isTrashEnabled={filesData?.is_trash_enabled}
+            isFsSuperUser={filesData?.is_fs_superuser}
+            superUser={filesData?.superuser}
+            superGroup={filesData?.supergroup}
+            users={filesData?.users}
+            groups={filesData?.groups}
             selectedFiles={selectedFiles}
             setLoadingFiles={setLoadingFiles}
             onSuccessfulAction={reloadData}

+ 1 - 0
desktop/core/src/desktop/js/reactComponents/FileChooser/api.ts

@@ -36,6 +36,7 @@ export const COPY_API_URL = '/api/v1/storage/copy';
 export const BULK_COPY_API_URL = '/api/v1/storage/copy/bulk';
 export const MOVE_API_URL = '/api/v1/storage/move';
 export const BULK_MOVE_API_URL = '/api/v1/storage/move/bulk';
+export const BULK_CHANGE_OWNER_API_URL = '/api/v1/storage/chown/bulk';
 export const UPLOAD_AVAILABLE_SPACE_URL = '/api/v1/taskserver/upload/available_space';
 
 export interface ApiFileSystem {