-
Notifications
You must be signed in to change notification settings - Fork 10
Folder Permission Management #729
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5bd1787
421276b
898947b
d007377
e7daa9e
9db56f6
059f83f
624a1eb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,393 @@ | ||
| 'use client'; | ||
|
|
||
| import { Form, Space, Button, Modal, Collapse, Flex } from 'antd'; | ||
| import { EditOutlined, DeleteOutlined } from '@ant-design/icons'; | ||
| import { ResourceActionType, ResourceType } from '@/lib/ability/caslAbility'; | ||
| import { FC, use, useEffect, useMemo, useState } from 'react'; | ||
| import { | ||
| ResourcePermissionInputs, | ||
| formDataToPermissions, | ||
| permissionsToFormData, | ||
| switchChecked, | ||
| } from './role-permissions-helper'; | ||
| import { addRole, handleFolderRoleChanges } from '@/lib/data/roles'; | ||
| import { Role, RoleWithChildren } from '@/lib/data/role-schema'; | ||
| import { useEnvironment } from '@/components/auth-can'; | ||
| import { EnvVarsContext } from '@/components/env-vars-context'; | ||
| import { Folder } from '@/lib/data/folder-schema'; | ||
| import { FolderTree } from '@/components/FolderTree'; | ||
| import { useRouter } from 'next/navigation'; | ||
| import { truthyFilter } from '@/lib/typescript-utils'; | ||
|
|
||
| type SelectionFolder = { id: string; name: string; type: 'folder' }; | ||
|
|
||
| type PermissionCategory = { | ||
| key: string; | ||
| title: string; | ||
| resource: ResourceType; | ||
| permissions: { | ||
| key: string; | ||
| title: string; | ||
| description: string; | ||
| permission: ResourceActionType; | ||
| }[]; | ||
| }; | ||
|
|
||
| const FolderSelection: React.FC<{ | ||
| defaultFolders: SelectionFolder[]; | ||
| onSubmit: (selected: SelectionFolder[]) => void; | ||
| notSelectable: string[]; | ||
| }> = ({ defaultFolders, onSubmit, notSelectable }) => { | ||
| const [selectedFolders, setSelectedFolders] = useState<SelectionFolder[]>(defaultFolders); | ||
|
|
||
| return ( | ||
| <> | ||
| <Modal | ||
| title="Choose a folder" | ||
| open={true} | ||
| onOk={() => onSubmit(selectedFolders)} | ||
| onCancel={() => onSubmit([])} | ||
| cancelText={null} | ||
| closeIcon={null} | ||
| > | ||
| <Space orientation="vertical" style={{ maxWidth: '100%' }}> | ||
| <Button | ||
| onClick={() => { | ||
| setSelectedFolders([]); | ||
| }} | ||
| type="default" | ||
| danger | ||
| > | ||
| Clear Folders | ||
| </Button> | ||
| <FolderTree<SelectionFolder> | ||
| newChildrenHook={({ nodes }) => nodes.filter((node) => node.element.type === 'folder')} | ||
| onMultiSelect={(elements) => { | ||
| setSelectedFolders(elements || []); | ||
| }} | ||
| selectedKeys={selectedFolders.map((f) => f.id)} | ||
| showRootAsFolder | ||
| notSelectableKeys={notSelectable} | ||
| /> | ||
| </Space> | ||
| </Modal> | ||
| </> | ||
| ); | ||
| }; | ||
|
|
||
| const basePermissionOptions: PermissionCategory[] = [ | ||
| { | ||
| key: 'process', | ||
| title: 'PROCESSES', | ||
| resource: 'Process', | ||
| permissions: [ | ||
| { | ||
| key: 'process_view', | ||
| title: 'View Processes', | ||
| description: 'Allows a user to view processes. (Enables the Processes view.)', | ||
| permission: 'view', | ||
| }, | ||
| { | ||
| key: 'process_manage', | ||
| title: 'Manage Processes', | ||
| description: 'Allows a user to create, modify and delete processes.', | ||
| permission: 'manage', | ||
| }, | ||
| ], | ||
| }, | ||
| { | ||
| key: 'folder', | ||
| title: 'Folders', | ||
| resource: 'Folder', | ||
| permissions: [ | ||
| { | ||
| key: 'folder_view', | ||
| title: 'View Folders', | ||
| description: 'Allows a user to view folders.', | ||
| permission: 'view', | ||
| }, | ||
| { | ||
| key: 'folder_manage', | ||
| title: 'Manage Folders', | ||
| description: 'Allows a user to create, modify and delete folders.', | ||
| permission: 'manage', | ||
| }, | ||
| ], | ||
| }, | ||
| { | ||
| key: 'executions', | ||
| title: 'EXECUTIONS', | ||
| resource: 'Execution', | ||
| permissions: [ | ||
| { | ||
| key: 'View Executions', | ||
| title: 'View Executions', | ||
| description: 'Allows a user to view all executions. (Enables the Executions view.)', | ||
| permission: 'view', | ||
| }, | ||
| { | ||
| key: 'Manage Executions', | ||
| title: 'Manage Executions', | ||
| description: 'Allows a user to to start, modify and delete process executions.', | ||
| permission: 'manage', | ||
| }, | ||
| ], | ||
| }, | ||
| ]; | ||
|
|
||
| const groupFolders = (role: RoleWithChildren, options: PermissionCategory[], folders: Folder[]) => { | ||
| const groups: Record<string, RoleWithChildren[]> = {}; | ||
|
|
||
| for (const r of role.children) { | ||
| let groupId = 0; | ||
|
|
||
| options.forEach((o) => { | ||
| o.permissions.forEach((p) => { | ||
| groupId += switchChecked(r.permissions, o.resource, p.permission) ? 1 : 0; | ||
| groupId <<= 1; | ||
| }); | ||
| }); | ||
|
|
||
| if (groups[groupId]) { | ||
| groups[groupId].push(r); | ||
| } else { | ||
| groups[groupId] = [r]; | ||
| } | ||
| } | ||
|
|
||
| return Object.values(groups).map((group) => ({ | ||
| folders: group.map((r) => { | ||
| const folder = folders.find((f) => r.parentId === f.id) || { | ||
| id: r.parentId!, | ||
| name: '', | ||
| type: 'folder', | ||
| }; | ||
|
|
||
| if ('parentId' in folder && !folder.parentId) folder.name = '< root >'; | ||
|
|
||
| return { ...folder, type: 'folder' as const }; | ||
| }), | ||
| permissions: permissionsToFormData(options, group[0].permissions), | ||
| })); | ||
| }; | ||
|
|
||
| const FolderPermissions: FC<{ role: RoleWithChildren; folders: Folder[] }> = ({ | ||
| role, | ||
| folders, | ||
| }) => { | ||
| const environment = useEnvironment(); | ||
| const envVars = use(EnvVarsContext); | ||
|
|
||
| const router = useRouter(); | ||
|
|
||
| const [form] = Form.useForm(); | ||
|
|
||
| const [loading, setLoading] = useState(false); | ||
|
|
||
| const options = basePermissionOptions.filter((permissionCategory) => { | ||
| return ( | ||
| envVars.PROCEED_PUBLIC_PROCESS_AUTOMATION_ACTIVE || | ||
| permissionCategory.resource !== 'Execution' | ||
| ); | ||
| }); | ||
|
|
||
| const [groups, setGroups] = useState(groupFolders(role, options, folders)); | ||
|
|
||
| useEffect(() => { | ||
| const values = Object.fromEntries( | ||
| groups.map((group, index) => [index.toString(), group.permissions]), | ||
| ); | ||
|
|
||
| form.setFieldsValue(values); | ||
| }, [groups, form]); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think including form in the dependency array could cause circular updates and I guess it doesn't need to be a dependency. This could also lead to unnecessary re-renders or infinite loops in edge cases. May be try to remove form from the dependencies. |
||
|
|
||
| const alreadySelectedFolders = groups | ||
| .map(({ folders }) => folders) | ||
| .flat() | ||
| .map((f) => f.id); | ||
|
|
||
| const [initialFolders, setInitialFolders] = useState<SelectionFolder[] | undefined>(); | ||
| const [groupInEditing, setGroupInEditing] = useState<number | undefined>(); | ||
| const [notSelectable, setNotSelectable] = useState<string[]>([]); | ||
|
|
||
| const items = groups.map((g, index) => ({ | ||
| key: index.toString(), | ||
| label: ( | ||
| <Flex justify="space-between"> | ||
| <div>{g.folders.map((f) => f.name || f.id).join(', ')}</div> | ||
| <Space.Compact> | ||
| <Button | ||
| size="small" | ||
| type="text" | ||
| icon={<EditOutlined />} | ||
| onClick={(e) => { | ||
| e.stopPropagation(); | ||
| setInitialFolders(g.folders); | ||
| setNotSelectable( | ||
| alreadySelectedFolders.filter((id) => !g.folders.some((f) => f.id === id)), | ||
| ); | ||
| setGroupInEditing(index); | ||
| }} | ||
| /> | ||
| <Button | ||
| size="small" | ||
| type="text" | ||
| icon={<DeleteOutlined />} | ||
| onClick={(e) => { | ||
| Modal.confirm({ | ||
| title: 'Are you sure you want to delete the permissions for these folders?', | ||
| content: | ||
| 'All users associated with the role will lose access rights to the specified folders', | ||
| onOk: () => setGroups([...groups.slice(0, index), ...groups.slice(index + 1)]), | ||
| }); | ||
| e.stopPropagation(); | ||
| }} | ||
| /> | ||
| </Space.Compact> | ||
| </Flex> | ||
| ), | ||
| children: ( | ||
| <ResourcePermissionInputs | ||
| pathPrefix={[index]} | ||
| options={options} | ||
| permissions={g.permissions} | ||
| /> | ||
| ), | ||
| forceRender: true, | ||
| })); | ||
|
|
||
| async function updateRoles() { | ||
| setLoading(true); | ||
|
|
||
| const values = (await form.validateFields()) as Record< | ||
| string, | ||
| Record<ResourceType, Record<ResourceActionType, boolean>> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If validateFields() or handleFolderRoleChanges() fails, then loading will never be set to false and users will not see any error message. Wrapping in try-catch with user feedback and a finally block for loading state might be a better option. |
||
| >; | ||
|
|
||
| const existingFolderRoles = Object.fromEntries( | ||
| role.children.map(({ id, parentId }) => [parentId, id]), | ||
| ); | ||
|
|
||
| const updates: { roleId: string; permissions: Role['permissions'] }[] = []; | ||
| const additions: Parameters<typeof addRole>[1][] = []; | ||
|
|
||
| Object.entries(values).forEach(([indexString, resource]) => { | ||
| const index = parseInt(indexString); | ||
| const permissions = formDataToPermissions(resource); | ||
|
|
||
| groups[index].folders.forEach((folder) => { | ||
| if (existingFolderRoles[folder.id]) { | ||
| updates.push({ roleId: existingFolderRoles[folder.id], permissions }); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what would happen if a folder is moved from one group to another. I mean, if I have understood it correctly, when a folder X moves from group A to group B, it will be in additions for group B, but the old role from group A won't be in removals unless group A is completely empty. This could create duplicate child roles for the same folder. |
||
| } else { | ||
| additions.push({ | ||
| name: `${role.name}-${folder.name || folder.id}`, | ||
| environmentId: environment.spaceId, | ||
| permissions, | ||
| parentRoleId: role.id, | ||
| parentId: folder.id, | ||
| }); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| // remove all child roles that refer to folders that are not mapped to rules anymore | ||
| const removals = role.children | ||
| .filter( | ||
| (child) => | ||
| !groups.some((group) => group.folders.some((folder) => folder.id === child.parentId)), | ||
| ) | ||
| .map((role) => role.id); | ||
|
|
||
| await handleFolderRoleChanges(environment.spaceId, role.id, additions, updates, removals); | ||
|
|
||
| router.refresh(); | ||
|
|
||
| setLoading(false); | ||
| } | ||
|
|
||
| // this is needed to ensure that the form only resets to the values from the last save and not | ||
| // to the initial values from when the page was opened | ||
| const initialFormValues = useMemo(() => { | ||
| const initialGroups = groupFolders(role, options, folders); | ||
|
|
||
| return Object.fromEntries( | ||
| initialGroups.map((group, index) => [ | ||
| index.toString(), | ||
| permissionsToFormData(options, group.permissions), | ||
| ]), | ||
| ); | ||
| }, [role, options, folders]); | ||
|
|
||
| return ( | ||
| <Form form={form} onFinish={updateRoles} initialValues={initialFormValues}> | ||
| {!!items.length && <Collapse items={items} accordion />} | ||
| <Button | ||
| style={{ marginTop: '10px' }} | ||
| block | ||
| onClick={() => { | ||
| setInitialFolders([]); | ||
| setNotSelectable(alreadySelectedFolders); | ||
| setGroupInEditing(undefined); | ||
| }} | ||
| > | ||
| New Folder(s) Selection | ||
| </Button> | ||
| {!!initialFolders && ( | ||
| <FolderSelection | ||
| notSelectable={notSelectable} | ||
| defaultFolders={initialFolders} | ||
| onSubmit={(selected) => { | ||
| const addingNewGroup = groupInEditing === undefined; | ||
| const index = addingNewGroup ? groups.length : groupInEditing; | ||
| const newPermissions = addingNewGroup ? {} : groups[index].permissions; | ||
|
|
||
| let newGroup; | ||
| if (selected.length) { | ||
| newGroup = { folders: selected, permissions: newPermissions }; | ||
| } | ||
|
|
||
| const values = form.getFieldsValue(); | ||
|
|
||
| // make sure to update the existing groups so the form is not overwritten with the | ||
| // initial values by the useEffect above | ||
| const updateGroup = (group: (typeof groups)[number], index: number) => { | ||
| return { ...group, permissions: values[index.toString()] }; | ||
| }; | ||
|
|
||
| // add a new group or update an existing group with different folders | ||
| setGroups( | ||
| [ | ||
| ...groups.slice(0, index).map(updateGroup), | ||
| newGroup, | ||
| ...groups.slice(index + 1).map(updateGroup), | ||
| ].filter(truthyFilter), | ||
| ); | ||
|
|
||
| setInitialFolders(undefined); | ||
| setGroupInEditing(undefined); | ||
| }} | ||
| /> | ||
| )} | ||
|
|
||
| <Flex justify="end" gap={5} style={{ marginTop: '20px', position: 'sticky', bottom: 0 }}> | ||
| <Button | ||
| loading={loading} | ||
| onClick={() => { | ||
| Modal.confirm({ | ||
| title: 'Undo Changes', | ||
| content: 'Are you sure that you want to undo all unsaved changes?', | ||
| onOk: () => setGroups(groupFolders(role, options, folders)), | ||
| }); | ||
| }} | ||
| > | ||
| Cancel | ||
| </Button> | ||
| <Button type="primary" htmlType="submit" loading={loading}> | ||
| Save | ||
| </Button> | ||
| </Flex> | ||
| </Form> | ||
| ); | ||
| }; | ||
|
|
||
| export default FolderPermissions; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not quite sure what will happen on code level if we submit it with empty array here in this case. Can you please specify what are we trying to achieve with this. Because normally, what I would have done here is to just
onCancel={() => onSubmit(initialFolders)}i.e., restoring the initial state